本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

ファイルをドラッグするとアップロード欄の枠が光り、落とすと進捗が満ちる

デモ

操作:ドラッグ。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

ファイルを持ったポインタが枠に入ると、破線の枠が明るい色になって地がほのかに明るみ、外側に 3 重の輪が灯ります。同時にアイコンが持ち上がり、文言が「ドロップしてアップロード」に替わります。落とすと輪が一度広がって戻り、枠の中身がファイルの行に替わって、進捗の帯が満ちていきます。送り終えると枠と帯が緑になり、% がチェックに替わります。

輪はぼかしを使わず、box-shadow の広がり(spread)だけで 3px・8px・16px の 3 重にし、外ほど薄くしています(35%・14%・6%)。ぼかした影より描くのが軽く、縁がくっきりしたまま光って見えます。アイコンは 0.35 秒の back.out で 8px 持ち上がり、チェックは 0.4 倍から弾んで出ます。成功の緑は暗い地の上では線だけだと目立たないので、完了した枠は地にも緑を 28% 混ぜて塗ります。

dragenter と dragleave は、枠の中の子要素に出入りするたびにも起きます。そのまま光を消すと、アイコンや文字の上を通るたびに点滅するので、入った深さを数えて 0 に戻ったときだけ消します。また、枠の外に落としたファイルはブラウザが開いてしまい、ページから離れます。ページ全体の dragover で既定の動きを止め、枠の外では落とせない印にしておきます。

fetch では送る側の進み具合が取れないので、XMLHttpRequest の upload の progress で受けます。届く間隔はまちまちなので、届くたびに今の値から次の値へ 0.4 秒で補間し直すと、帯が跳ばずに伸びます。キーボードや指で使う人のために「ファイルを選択」のボタンも置き、選んだときも落としたときと同じ流れで送ります。形式と大きさは送る前に確かめ、合わないときは枠の下に理由を出します。

つまみは、輪の太さと濃さ、完了の緑の濃さ、受けるファイルの形式と上限です。送り先・上限・受ける形式は HTML の属性で決めるので、スクリプトを触らずに案件に合わせられます。ファイルのアップロード、書類の添付、プロフィール画像の差し替えのフォームに向いています。

コード

HTML
<section class="upload" aria-labelledby="upload-title">
  <h2 class="upload__title" id="upload-title">ファイルのアップロード</h2>
  <p class="upload__lead">デザインチームにファイルを共有します。1ファイル50MBまで。</p>
  <!-- 送り先は data-upload、1 ファイルの上限は data-max-size(MB)、受けるファイルは input の accept で決める -->
  <div class="drop" data-upload="/upload/" data-max-size="50">
    <div class="drop__idle">
      <span class="drop__badge" aria-hidden="true">
        <svg viewBox="0 0 24 24"><path d="M12 15V4.5M7.5 9 12 4.5 16.5 9" /><path d="M4 14.5v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" /></svg>
      </span>
      <p class="drop__lead">
        <span>ここにファイルを<span class="drop__phrase">ドラッグ&ドロップ</span></span>
        <span class="drop__release" aria-hidden="true">ドロップしてアップロード</span>
      </p>
      <p class="drop__hint">または<button class="drop__browse" type="button">ファイルを選択</button>(PNG・PDF・ZIP)</p>
      <input class="drop__input" type="file" name="file" accept=".png,.pdf,.zip" hidden>
    </div>
    <div class="drop__file">
      <svg class="drop__doc" viewBox="0 0 40 50" aria-hidden="true">
        <path class="drop__paper" d="M5 0h23l12 12v33a5 5 0 0 1-5 5H5a5 5 0 0 1-5-5V5a5 5 0 0 1 5-5z" />
        <path class="drop__ink" d="M28 0l12 12h-8a4 4 0 0 1-4-4zM7 14h26v2.5H7zm0 6h20v2.5H7zm0 6h23v2.5H7z" />
        <path class="drop__band" d="M0 35.5h19.5a3 3 0 0 1 3 3v2.5a3 3 0 0 1-3 3H0z" />
      </svg>
      <div>
        <p class="drop__name"></p>
        <p class="drop__meta"><span class="drop__size"></span><span class="drop__complete">アップロード完了</span></p>
      </div>
      <div class="drop__end" aria-hidden="true">
        <span class="drop__percent"></span>
        <span class="drop__check"><svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg></span>
      </div>
      <div class="drop__track" role="progressbar" aria-label="アップロードの進み具合" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
        <span class="drop__bar"></span>
      </div>
    </div>
    <!-- 知らせの文。{name} にファイル名が入る。エラーのときだけ枠の下に見せる -->
    <p class="drop__status" role="status"
       data-done="{name}のアップロードが完了しました"
       data-invalid="PNG・PDF・ZIPのファイルを選んでください"
       data-too-large="50MBまでのファイルを選んでください"
       data-failed="アップロードできませんでした。もう一度お試しください"></p>
  </div>
  <div class="upload__footer">
    <p>共有する前にウイルスチェックを行います。</p>
    <a class="upload__done" href="/assets/">完了</a>
  </div>
</section>

CSS
/* 光の広がりと色を補間できるように、数と色として登録する */
@property --spread { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --glow { syntax: "<color>"; inherits: false; initial-value: #eeece6; }

.upload {
  --surface: #262626;                /* カードの面 */
  --ink: #eeece6;                    /* 文字と、ファイルを持って入ったときの枠・帯 */
  --mute: #a8a6a0;                   /* 補足の文字 */
  --rule: rgba(238, 236, 230, 0.14); /* 待っているときの破線と、帯の地 */
  --success: #4f8a64;                /* 完了の枠・帯・チェック */
  --success-glow: #6ba37f;           /* 完了の光(暗い緑のままだと光が弱く見える) */
  --error: #e07a62;                  /* 送れなかったときの文 */
  max-width: 672px;
  box-sizing: border-box;
  padding: 48px;
  border-radius: 4px;
  background-color: var(--surface);
  color: var(--ink);
}

.upload__title { margin: 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.upload__lead { margin: 8px 0 0; color: var(--mute); font-size: 14px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }

/* 待ちの中身とファイルの行を同じマスに重ねる。光はぼかさず、spread だけの輪を 3 重にして外ほど薄くする */
.drop {
  --glow: var(--ink);
  position: relative;
  display: grid;
  place-items: center;
  min-height: 256px;
  margin-top: 32px;
  padding: 24px;
  box-sizing: border-box;
  border: 1px dashed var(--rule);
  border-radius: 4px;
  box-shadow:
    0 0 0 calc(var(--spread) * 3px) color-mix(in srgb, var(--glow) 35%, transparent),
    0 0 0 calc(var(--spread) * 8px) color-mix(in srgb, var(--glow) 14%, transparent),
    0 0 0 calc(var(--spread) * 16px) color-mix(in srgb, var(--glow) 6%, transparent);
  transition: border-color 0.25s, background-color 0.3s, --glow 0.3s, --spread 0.25s ease-out;
}

.drop:is([data-state="over"], [data-state="busy"]) { --spread: 1; border-color: var(--ink); background-color: color-mix(in srgb, var(--ink) 4%, transparent); }
.drop[data-state="busy"] { animation: drop-pulse 0.5s; }
.drop[data-state="done"] { --spread: 1; --glow: var(--success-glow); border-color: var(--success); background-color: color-mix(in srgb, var(--success) 28%, transparent); }
/* 落とした合図に、輪を一度広げて戻す */
@keyframes drop-pulse { 30% { --spread: 1.3; } }

.drop__idle, .drop__file { grid-area: 1 / 1; }
.drop__idle { display: flex; flex-direction: column; align-items: center; text-align: center; }
.drop__badge { display: grid; place-items: center; width: 72px; height: 72px; border-radius: 50%; background-color: color-mix(in srgb, var(--ink) 8%, transparent); color: var(--mute); transition: background-color 0.25s, color 0.25s; }
.drop[data-state="over"] .drop__badge { background-color: color-mix(in srgb, var(--ink) 16%, transparent); color: var(--ink); }
.drop__badge svg { width: 33px; height: 33px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }

/* 2 つの文言を同じ場所に重ね、JS が入れ替える */
.drop__lead { display: grid; margin: 16px 0 0; font-size: 18px; font-weight: 600; line-height: 32px; letter-spacing: 0.04em; word-break: auto-phrase; }
.drop__lead > span { grid-area: 1 / 1; }
.drop__release { visibility: hidden; opacity: 0; }
.drop__phrase { white-space: nowrap; }
.drop__hint { margin: 8px 0 0; color: var(--mute); font-size: 13px; line-height: 1.8; letter-spacing: 0.04em; }
.drop__browse { padding: 0; border: 0; background: none; color: var(--ink); font: inherit; font-weight: 600; text-decoration: underline 1px; text-underline-offset: 0.3em; cursor: pointer; }
.drop__browse:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; border-radius: 2px; }

/* ファイルの行: アイコン・名前と状態・% とチェック、下に進捗の帯 */
.drop__file { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; column-gap: 16px; width: min(100%, 480px); visibility: hidden; opacity: 0; }
.drop__doc { width: 40px; height: 50px; }
.drop__paper { fill: #ffffff; }
.drop__ink { fill: #d6d4ce; }
.drop__band { fill: #b27771; }
.drop__name { margin: 0; overflow: hidden; font-size: 16px; font-weight: 600; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
.drop__meta { display: grid; margin: 4px 0 0; font-size: 13px; line-height: 24px; letter-spacing: 0.04em; }
.drop__meta span, .drop__end > * { grid-area: 1 / 1; }
.drop__size { color: var(--mute); font-variant-numeric: tabular-nums; white-space: nowrap; }
.drop__complete { font-weight: 600; visibility: hidden; opacity: 0; }
.drop__end { display: grid; place-items: center end; width: 64px; height: 32px; }
.drop__percent { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
.drop__check { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; background-color: var(--success); visibility: hidden; opacity: 0; }
.drop__check svg { width: 19px; height: 19px; fill: none; stroke: #ffffff; stroke-width: 3.2; stroke-linecap: round; stroke-linejoin: round; }
.drop__track { grid-column: 1 / -1; height: 8px; margin-top: 16px; overflow: hidden; border-radius: 8px; background-color: var(--rule); }
.drop__bar { display: block; width: 0; height: 100%; border-radius: inherit; background-color: var(--ink); transition: background-color 0.3s; }
.drop[data-state="done"] .drop__bar { background-color: var(--success); }

/* 知らせは読み上げに届け、見た目にはエラーのときだけ枠の下に出す */
.drop__status { position: absolute; left: 16px; right: 16px; bottom: 12px; margin: 0; color: var(--error); font-size: 13px; letter-spacing: 0.04em; text-align: center; word-break: auto-phrase; }
.drop__status:not(.is-error) { clip-path: inset(50%); }
.upload__footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; margin-top: 32px; color: var(--mute); font-size: 13px; letter-spacing: 0.04em; }
.upload__footer p { margin: 0; }
.upload__done { display: inline-grid; place-items: center; height: 48px; padding: 0 32px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--ink); color: var(--ink); font-size: 14px; font-weight: 600; text-decoration: none; }
.upload__done:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

@media (max-width: 480px) {
  .upload { padding: 32px 24px; }
  .drop { padding-bottom: 64px; }
  .drop__lead { font-size: 16px; }
}

@media (prefers-reduced-motion: reduce) {
  .drop, .drop__badge, .drop__bar { transition: none; animation: none; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const MB = 1024 * 1024;

// ファイルは枠の中だけで落とせるようにする。既定のままだと、枠の外に落としたファイルをブラウザが開いてページから離れる
document.addEventListener("dragover", (event) => {
  if (!event.dataTransfer?.types.includes("Files") || event.target.closest?.('input[type="file"]')) return;
  event.preventDefault();
  event.dataTransfer.dropEffect = event.target.closest?.('.drop:not([data-state="busy"])') ? "copy" : "none";
});

document.querySelectorAll(".drop").forEach((zone) => {
  const parts = ["idle", "file", "input", "badge", "release", "status", "name", "size", "complete", "percent", "check", "track", "bar"];
  const [idle, row, input, badge, release, status, name, size, complete, percent, check, track, bar] = parts.map((part) => zone.querySelector(`.drop__${part}`));
  const progress = { value: 0, total: 0 };
  let depth = 0; // dragenter と dragleave は子要素の出入りでも飛ぶので、深さを数える
  let busy = false;
  let rest = ""; // ファイルを持ち出したときに戻る状態("" か "done")。枠・地・光の色は data-state で CSS が移す
  const say = (key, file, error = false) => {
    status.textContent = key ? status.dataset[key].replace("{name}", file?.name ?? "") : "";
    status.classList.toggle("is-error", error);
  };
  const fmt = (bytes) => (progress.total < MB ? `${Math.round(bytes / 1024)}KB` : `${(bytes / MB).toFixed(1)}MB`);
  const draw = () => {
    bar.style.width = `${progress.value}%`;
    percent.textContent = `${Math.round(progress.value)}%`;
    size.textContent = `${fmt((progress.total * progress.value) / 100)} / ${fmt(progress.total)}`;
    track.setAttribute("aria-valuenow", Math.round(progress.value));
  };
  // 進み具合が届くたびに、今の値から次の値へ補間し直す(届く間隔がまちまちでも帯が跳ばない)
  const follow = (value, onComplete) => gsap.to(progress, { value, duration: reduceMotion ? 0 : 0.4, ease: "power2.out", overwrite: true, onUpdate: draw, onComplete });

  // ファイルを持って入ったとき: アイコンが持ち上がり、文言が替わる
  const over = gsap.timeline({ paused: true })
    .to(badge, { y: -8, duration: 0.35, ease: "back.out(2)" })
    .to(zone.querySelector(".drop__lead span"), { autoAlpha: 0, duration: 0.12, ease: "none" }, 0)
    .to(release, { autoAlpha: 1, duration: 0.2, ease: "none" }, 0.12);
  // 落としたとき: 中身をファイルの行に替える(送れなかったら逆にたどって戻す)
  const swap = gsap.timeline({ paused: true })
    .to(idle, { autoAlpha: 0, duration: 0.2, ease: "none" })
    .fromTo(row, { autoAlpha: 0, y: 10 }, { autoAlpha: 1, y: 0, duration: 0.35, ease: "power2.out" }, 0.15);
  // 送れたとき: % がチェックに、大きさの文字が「アップロード完了」に替わる(枠と帯の緑は CSS)
  const done = gsap.timeline({ paused: true })
    .to([percent, size], { autoAlpha: 0, duration: 0.12, ease: "none" })
    .fromTo(check, { autoAlpha: 0, scale: 0.4 }, { autoAlpha: 1, scale: 1, duration: 0.4, ease: "back.out(3)" }, 0.1)
    .to(complete, { autoAlpha: 1, duration: 0.2, ease: "none" }, 0.12);
  if (reduceMotion) [over, swap, done].forEach((line) => line.timeScale(20)); // ほぼ一瞬で切り替える

  const hover = (on) => {
    if (on) say("");
    zone.dataset.state = on ? "over" : rest;
    over[on ? "play" : "reverse"]();
  };
  function start(file) {
    const ok = input.accept.split(",").some((ext) => file.name.toLowerCase().endsWith(ext.trim()));
    if (!ok || file.size > zone.dataset.maxSize * MB) {
      hover(false);
      return say(ok ? "tooLarge" : "invalid", file, true);
    }
    busy = true;
    Object.assign(progress, { value: 0, total: file.size });
    name.textContent = file.name;
    draw();
    zone.dataset.state = "busy"; // 落とした合図に、CSS が輪を一度広げる
    done.pause(0);
    swap.play();
    const xhr = new XMLHttpRequest(); // fetch では送る側の進み具合が取れない
    const body = new FormData();
    body.append(input.name, file);
    xhr.upload.addEventListener("progress", (event) => event.lengthComputable && follow((event.loaded / event.total) * 100));
    xhr.addEventListener("loadend", () => (xhr.status >= 200 && xhr.status < 300 ? follow(100, () => finish(file)) : finish()));
    xhr.open("POST", zone.dataset.upload);
    xhr.send(body);
  }
  // 送れたら完了の絵にし、送れなかったら待ちの状態に戻して知らせる
  function finish(file) {
    busy = false;
    rest = zone.dataset.state = file ? "done" : "";
    say(file ? "done" : "failed", file, !file);
    if (file) return done.play();
    over.pause(0);
    swap.reverse();
  }

  zone.addEventListener("dragenter", (event) => !busy && event.dataTransfer.types.includes("Files") && depth++ === 0 && hover(true));
  zone.addEventListener("dragleave", () => depth > 0 && --depth === 0 && hover(false));
  zone.addEventListener("drop", (event) => {
    event.preventDefault();
    depth = 0;
    if (!busy && event.dataTransfer.files[0]) start(event.dataTransfer.files[0]);
  });
  // ボタンから選んだときも同じ流れにする。同じファイルを選び直しても change が起きるよう、値を空にしておく
  zone.querySelector(".drop__browse").addEventListener("click", () => !busy && input.click());
  input.addEventListener("change", () => {
    if (input.files[0]) start(input.files[0]);
    input.value = "";
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。