デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- 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 つの入力を受けて下の複数の欄へ値を順に配る動きです。ずらしの幅と光の色がつまみで、通販の購入手続き、会員登録、資料請求の住所の入力に向いています。
コード
<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>
.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; }
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");
}));
});