デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ラベルを、入力欄の値が並ぶ行にぴったり重ねて置きます。欄にフォーカスが入ると、ラベルは縮みながら行の上へ移り、空いた場所に値を打てるようになります。同時に、欄の下の細い罫線の上を、太い線が中央から左右へ伸びていきます。
ラベルは 0.35 秒の power3.out で、欄の高さの 0.4 倍だけ持ち上がり、大きさは 0.72 倍(18px なら 13px)になります。下線はラベルと同時に動き出しますが、端まで届くのに 0.45 秒かかるので、ラベルが止まったあとも少しだけ伸び続けます。フォーカスが外れたときは、同じ線を逆にたどって中央へ閉じます。
ラベルを縮めるときは、transform-origin を左端にします。既定の中央のままだと、縮んだぶんラベルが右へ寄り、下に並ぶ値の左端とそろいません。上がりきった位置がひと文字ぶんずれるので、並んだ欄では目立ちます。
フォーカスが外れるたびにラベルを必ず戻すと、入力済みの値の上にラベルが重なって、どちらも読めなくなります。値が空のときだけ中へ戻し、値があれば上に残して色だけ戻します。自動入力で最初から値が入っている欄も、動かさずに上げた状態から始めます。
下線は clip-path の切り取る量を動かして開きます。scaleX で伸ばすと、補間の途中で線の端がにじむことがあります。開く向きを左からに変える、ラベルの縮尺を浅くするなど、つまみは多くありません。会員登録、ログイン、お問い合わせ、決済のフォームの入力欄に向いています。
コード
<form class="signup" action="/signup/" method="post">
<!-- ラベルは for で欄に結ぶ。CSS で値の行に重ねるので、読む順は label → input のままでよい -->
<div class="field">
<label class="field__label" for="signup-name">お名前</label>
<input class="field__input" id="signup-name" type="text" name="name" autocomplete="name">
<span class="field__line" aria-hidden="true"></span>
</div>
<div class="field">
<label class="field__label" for="signup-email">メールアドレス</label>
<input class="field__input" id="signup-email" type="email" name="email" autocomplete="email">
<span class="field__line" aria-hidden="true"></span>
</div>
<div class="field">
<label class="field__label" for="signup-password">パスワード</label>
<input class="field__input" id="signup-password" type="password" name="password" autocomplete="new-password">
<span class="field__line" aria-hidden="true"></span>
</div>
<button class="signup__submit" type="submit">無料ではじめる</button>
</form>
.signup {
--ink: #262626; /* 文字と、フォーカスしたラベル・下線の色 */
--mute: #6b6b6b; /* 休んでいるラベルの色 */
--rule: rgba(38, 38, 38, 0.12); /* 休んでいる下線の色 */
--field-height: 64px;
--row-height: 24px; /* 値の行の高さ */
--row-gap: 12px; /* 値の行と下線のあいだ */
--font-size: 18px;
display: flex;
flex-direction: column;
gap: 8px;
color: var(--ink);
}
.field {
position: relative;
height: var(--field-height);
}
/* 休んでいる下線。フォーカスの線はこの上に重なる */
.field::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background-color: var(--rule);
}
/* ラベルは値の行にぴったり重ねる。左端を起点に縮めるので、原点を左に寄せる */
.field__label {
position: absolute;
left: 0;
bottom: var(--row-gap);
height: var(--row-height);
line-height: var(--row-height);
color: var(--mute);
font-size: var(--font-size);
font-weight: 600;
letter-spacing: 0.04em;
transform-origin: 0 50%;
pointer-events: none;
}
.field__input {
position: absolute;
left: 0;
bottom: var(--row-gap);
width: 100%;
height: var(--row-height);
padding: 0;
border: 0;
background: none;
color: var(--ink);
font: inherit;
font-size: var(--font-size);
font-weight: 500;
-webkit-appearance: none;
appearance: none;
}
/* 下線とラベルの色がフォーカスを示すので、既定の輪は消す */
.field__input:focus {
outline: none;
}
/* フォーカスで伸びる線。中央だけに縮めておき、左右へ開く */
.field__line {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background-color: var(--ink);
clip-path: inset(0% 50% 0% 50%);
}
.signup__submit {
margin-top: 32px;
height: 48px;
border: 0;
border-radius: 999px;
background-color: var(--ink);
color: #ffffff;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.signup__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 FLOAT_SCALE = 0.72; // 上に移ったラベルの大きさ(18px なら 13px になる)
document.querySelectorAll(".field").forEach((field) => {
const input = field.querySelector(".field__input");
const label = field.querySelector(".field__label");
const line = field.querySelector(".field__line");
const styles = getComputedStyle(field);
// 色は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
const mute = styles.getPropertyValue("--mute").trim();
const ink = styles.getPropertyValue("--ink").trim();
// 持ち上げる量も、組み上がった欄の高さから出す
const lift = Math.round(field.offsetHeight * 0.4);
const float = gsap
.timeline({ paused: true })
.fromTo(label, { y: 0, scale: 1 }, { y: -lift, scale: FLOAT_SCALE, duration: 0.35, ease: "power3.out" });
const focus = gsap
.timeline({ paused: true })
// 値の数をそろえないと、途中の形が作れずに一瞬で切り替わる
.fromTo(line, { clipPath: "inset(0% 50% 0% 50%)" }, { clipPath: "inset(0% 0% 0% 0%)", duration: 0.45, ease: "power3.out" }, 0)
.fromTo(label, { color: mute }, { color: ink, duration: 0.2, ease: "none" }, 0);
// 動きを減らす設定では、ほぼ一瞬で切り替える
if (reduceMotion) {
float.timeScale(20);
focus.timeScale(20);
}
// 自動入力などで最初から値があるときは、動かさずに上げておく
if (input.value) float.progress(1);
input.addEventListener("focus", () => {
float.play();
focus.play();
});
input.addEventListener("blur", () => {
focus.reverse();
if (input.value) return; // 値があればラベルは上に残す
float.reverse();
});
});