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

郵便番号を入れると、都道府県・市区町村・町名が順に流れ込んで埋まる

デモ

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

仕様

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

しくみと調整

郵便番号の欄で 7 桁がそろうと住所を引き、返ってきた都道府県・市区町村・町名を、上の欄から順に入れます。値は欄にすぐ入れ、見た目だけ、欄の上に重ねた字の層で 1 字ずつ左から流し込みます。入った欄は枠が一度だけ淡く光り、自動で入ったしるしとして地に淡い色が残ります。3 つがそろうと、焦点を番地の欄へ移します。

字は 12px 左から 0.4 秒の power3.out で入り、字ごとに 0.04 秒、欄ごとに 0.22 秒ずらします。枠の光は 0.18 秒で強まって 0.7 秒で引き、欄の外に 4px の淡い輪を出します。焦点を移すのは、最後の欄が入り始めて 0.45 秒後です。流れ込みを読み終えるころに移るので、目が自然に番地の欄へ下ります。

流している間は欄の文字を透明にし、重ねた層だけを見せます。層は読み上げから外してあり、値は最初から欄に入っているので、送信にも読み上げにも流れる途中の字は混ざりません。層と欄は余白と字の組みを同じにしてあるので、流し終えて層を外しても字は跳ねません。焦点は activeElement が郵便番号の欄のときだけ移し、引いている間にほかの欄へ移った人の焦点は奪いません。

全角の数字も半角に直し、数字だけを「3 桁-4 桁」の形に整えます。かな漢字変換の途中は値を書き換えず、確定してから整えます。見つからないときは案内の文を替え、aria-live で読み上げにも届けます。自動で入った欄を自分で書き換えると、しるしを外します。動きを減らす設定では、流さずにすぐ入れます。

住所を引く関数は架空の番号の表を返すだけなので、自分のサーバーや住所のデータを引く形に差し替えて使います。1 つの欄の中で数字をせり上げる認証コードの入力や、ラベルが浮く入力欄と違い、1 つの入力を受けて下の複数の欄へ値を順に配る動きです。ずらしの幅と光の色がつまみで、通販の購入手続き、会員登録、資料請求の住所の入力に向いています。

コード

HTML
<form class="address" action="/checkout/address/" method="post">
  <h2 class="address__title">お届け先の住所</h2>
  <div class="address__field">
    <label class="address__label" for="address-zip">郵便番号</label>
    <div class="address__zip">
      <div class="address__box">
        <input class="address__input" id="address-zip" name="postal_code" type="text" inputmode="numeric" autocomplete="postal-code" aria-describedby="address-zip-hint" data-zip>
      </div>
      <!-- 引けた・引けなかったの文は data-* に置き、JS が差し替える。aria-live で読み上げにも届く -->
      <p class="address__hint" id="address-zip-hint" aria-live="polite" data-found="住所を自動で入力しました" data-missing="この郵便番号の住所が見つかりませんでした">ハイフンなしでも入力できます</p>
    </div>
  </div>
  <!-- data-fill の欄に、引いた住所が上から順に入る。流れる字の層は読み上げから外す -->
  <div class="address__field">
    <label class="address__label" for="address-pref">都道府県</label>
    <div class="address__box" data-fill>
      <input class="address__input" id="address-pref" name="prefecture" type="text" autocomplete="address-level1">
      <span class="address__flow" aria-hidden="true"></span>
    </div>
  </div>
  <div class="address__field">
    <label class="address__label" for="address-city">市区町村</label>
    <div class="address__box" data-fill>
      <input class="address__input" id="address-city" name="city" type="text" autocomplete="address-level2">
      <span class="address__flow" aria-hidden="true"></span>
    </div>
  </div>
  <div class="address__field">
    <label class="address__label" for="address-town">町名</label>
    <div class="address__box" data-fill>
      <input class="address__input" id="address-town" name="town" type="text" autocomplete="address-level3">
      <span class="address__flow" aria-hidden="true"></span>
    </div>
  </div>
  <div class="address__field">
    <label class="address__label" for="address-line">番地・建物名</label>
    <div class="address__box">
      <input class="address__input" id="address-line" name="address_line" type="text" autocomplete="address-line1" data-next>
    </div>
  </div>
  <button class="address__submit" type="submit">お支払いへ進む</button>
</form>

CSS
.address {
  --ink: #262626;
  --mute: #6b6b6b;                /* 案内の文字(16px 未満なので小さい補足の色) */
  --line: rgba(38, 38, 38, 0.12); /* 休んでいる枠 */
  --surface: #ffffff;
  --glow-color: #739374;          /* 入った欄の光と、自動で入ったしるし(セージ) */
  --field-height: 44px;
  display: flex;
  flex-direction: column;
  gap: 16px;
  max-width: 448px;
  color: var(--ink);
}

.address__title {
  margin: 0 0 12px;
  font-size: 20px;
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.3;
}

.address__field { display: grid; gap: 8px; }
.address__label { font-size: 13px; font-weight: 600; letter-spacing: 0.04em; line-height: 20px; }

.address__zip {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 16px;
}

.address__zip .address__box { flex: none; width: 176px; }

/* 枠は inset の影で引く。--glow(0〜1)を GSAP が動かし、枠をセージへ寄せて外に淡い輪を出す */
.address__box {
  --edge: var(--line);
  --glow: 0;
  position: relative;
  border-radius: 4px;
  background-color: var(--surface);
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--glow-color) calc(var(--glow) * 100%), var(--edge)),
    0 0 0 4px color-mix(in srgb, var(--glow-color) calc(var(--glow) * 30%), transparent);
}

.address__box:focus-within { --edge: var(--ink); }
/* 自動で入った欄のしるし。自分で書き換えると外れる */
.address__box.is-filled { background-color: color-mix(in srgb, var(--glow-color) 10%, var(--surface)); }

.address__input,
.address__flow {
  box-sizing: border-box;
  width: 100%;
  height: var(--field-height);
  margin: 0;
  padding: 0 16px;
  border: 0;
  background: none;
  color: var(--ink);
  font-family: inherit;
  font-size: 16px;
  font-weight: 500;
  letter-spacing: 0.04em;
  font-variant-numeric: tabular-nums;
  outline: none;
}

/* 流れている間だけ欄の文字を透かし、同じ位置に重ねた字の層を見せる */
.address__box.is-flowing .address__input { color: transparent; }

.address__flow {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  white-space: pre;
  pointer-events: none;
}

.address__flow span { display: inline-block; }
.address__hint { margin: 0; color: var(--mute); font-size: 13px; letter-spacing: 0.04em; line-height: 1.4; }
.address__hint.is-found { color: var(--ink); font-weight: 600; }

.address__submit {
  margin-top: 16px;
  height: 48px;
  border: 0;
  border-radius: 999px;
  background-color: var(--ink);
  color: #ffffff;
  font-family: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.address__submit:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const FIELD_GAP = 0.22; // 欄ごとのずれ(秒)
const CHAR_STEP = 0.04; // 字ごとのずれ(秒)
const CHAR_TIME = 0.4;  // 1 字が流れ込む長さ(秒)
const HANDOFF = 0.45;   // 最後の欄が入り始めてから、番地の欄へ焦点を移すまで(秒)

// 住所を引く関数。案件では、自分のサーバーや住所のデータを引く形に差し替える(見つからなければ null)
const SAMPLE = {
  "1234567": ["福岡県", "みどり野市", "若葉台"],
  "2345678": ["長崎県", "しおかぜ市", "港が丘"],
  "3456789": ["大分県", "こもれび町", "森の台"],
};
async function lookup(zip) {
  return SAMPLE[zip] ?? null;
}

document.querySelectorAll(".address").forEach((form) => {
  const zip = form.querySelector("[data-zip]");
  const hint = form.querySelector(".address__hint");
  const boxes = [...form.querySelectorAll("[data-fill]")];
  const next = form.querySelector("[data-next]");
  const rest = hint.textContent;
  let asked = "";
  let tl;

  // 案内は、前の文を消しきってから次の文を出す(重ねると読めない)
  const say = (text, found) => {
    if (hint.textContent === text) return;
    gsap.timeline()
      .to(hint, { opacity: 0, duration: reduceMotion ? 0 : 0.15 })
      .add(() => {
        hint.textContent = text;
        hint.classList.toggle("is-found", found);
      })
      .to(hint, { opacity: 1, duration: reduceMotion ? 0 : 0.2 });
  };

  const settle = (box) => {
    box.classList.remove("is-flowing");
    box.querySelector(".address__flow").replaceChildren();
  };

  function fill(parts) {
    tl?.kill(); // 打ち直したときは、前の流れを止めて素の欄に戻す(先へ飛ばすと焦点の移動まで起きる)
    boxes.forEach(settle);
    gsap.set(boxes, { "--glow": 0 });
    tl = gsap.timeline();
    boxes.forEach((box, i) => {
      const flow = box.querySelector(".address__flow");
      box.querySelector("input").value = parts[i]; // 値はすぐ入れ、見た目だけ上の層で流す
      box.classList.add("is-filled");
      if (reduceMotion) return;
      flow.replaceChildren(...[...parts[i]].map((c) => Object.assign(document.createElement("span"), { textContent: c })));
      box.classList.add("is-flowing");
      const at = i * FIELD_GAP;
      tl.fromTo(flow.children, { x: -12, opacity: 0 }, { x: 0, opacity: 1, duration: CHAR_TIME, stagger: CHAR_STEP, ease: "power3.out" }, at)
        .fromTo(box, { "--glow": 0 }, { "--glow": 1, duration: 0.18, ease: "power2.out" }, at)
        .to(box, { "--glow": 0, duration: 0.7, ease: "power2.inOut" }, at + 0.18)
        .add(() => settle(box), at + CHAR_TIME + CHAR_STEP * (parts[i].length - 1));
    });
    // 郵便番号の欄にいる間だけ、番地の欄へ焦点を移す(ほかへ移った人の焦点は奪わない)
    tl.add(() => {
      if (document.activeElement === zip) next.focus();
    }, reduceMotion ? 0 : FIELD_GAP * (boxes.length - 1) + HANDOFF);
  }

  async function update() {
    // 全角の数字も半角にし、数字だけを「3 桁-4 桁」に整える
    const digits = zip.value.replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0)).replace(/\D/g, "").slice(0, 7);
    const shown = digits.length > 3 ? `${digits.slice(0, 3)}-${digits.slice(3)}` : digits;
    if (zip.value !== shown) zip.value = shown;
    if (digits.length < 7) {
      asked = "";
      say(rest, false);
      return;
    }
    if (digits === asked) return;
    asked = digits;
    const parts = await lookup(digits);
    if (asked !== digits) return; // 引いている間に打ち直された
    if (!parts) return say(hint.dataset.missing, false);
    say(hint.dataset.found, true);
    fill(parts);
  }

  // かな漢字変換の途中は値を書き換えない(確定してから整える)
  zip.addEventListener("input", (e) => e.isComposing || update());
  zip.addEventListener("compositionend", update);
  // 自動で入った欄を自分で書き換えたら、しるしを外す
  boxes.forEach((box) => box.querySelector("input").addEventListener("input", () => {
    settle(box);
    box.classList.remove("is-filled");
  }));
});

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

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