09シェブロンがばねで回り、選ぶとラベルが上がるセレクト欄
開く角度をばねで回し、リストの高さを同じ値から導くので、開閉とラベルの上昇が一つの状態で噛み合う。
使いどころ国や職種を選ぶ選択欄
CHEV_STIFFOPEN_DEGPICK_DELAYOPT_H

<main class="stage">
<div class="fl is-demo" id="fl" data-demo="1">
<div class="fl__field">
<div class="fl__box" data-r="box">
<button class="fl__input fl__select" id="c1" data-r="btn" type="button" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="m1">
<span class="fl__val" data-r="val"></span>
</button>
<label class="fl__label" for="c1" id="l1" data-r="label">市区町村</label>
<svg class="fl__chev" data-r="chev" aria-hidden="true" width="24" height="24" viewBox="0 0 256 256"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
<ul class="fl__menu" id="m1" role="listbox" aria-labelledby="l1" data-r="menu">
<li class="fl__opt" role="option" id="m1o0" data-r="opt" aria-selected="false">ソウル</li>
<li class="fl__opt" role="option" id="m1o1" data-r="opt" aria-selected="false">釜山</li>
<li class="fl__opt" role="option" id="m1o2" data-r="opt" aria-selected="false">済州</li>
</ul>
</div>
</div>
<div class="fl__field">
<div class="fl__box" data-r="box">
<button class="fl__input fl__select" id="c2" data-r="btn" type="button" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="m2">
<span class="fl__val" data-r="val"></span>
</button>
<label class="fl__label" for="c2" id="l2" data-r="label">曜日</label>
<svg class="fl__chev" data-r="chev" aria-hidden="true" width="24" height="24" viewBox="0 0 256 256"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
<ul class="fl__menu fl__menu--up" id="m2" role="listbox" aria-labelledby="l2" data-r="menu">
<li class="fl__opt" role="option" id="m2o0" data-r="opt" aria-selected="false">月曜日</li>
<li class="fl__opt" role="option" id="m2o1" data-r="opt" aria-selected="false">水曜日</li>
<li class="fl__opt" role="option" id="m2o2" data-r="opt" aria-selected="false">金曜日</li>
</ul>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 열림 각도 스프링 하나에서 쉐브론·목록 높이·라벨 상승을 모두 파생한다. 라벨은 진짜 <label for> 이고 칸은 진짜 입력 요소다. 움직임 값은 JS 가 계산하고 CSS 는 모양만 맡는다.
@include stage($stage-ink);
$panel: mix($subject-cream, $stage-ink, 8%);
$box-bg: mix($subject-cream, $stage-ink, 5%);
$fg: $subject-cream;
$mute: $tick-mute;
$line: mix($subject-cream, $stage-ink, 32%);
$accent: $subject-lilac;
$err: $tick-down;
$menu-bg: mix($subject-cream, $stage-ink, 16%);
$box-h: 52px;
.fl {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
padding: $sp-5;
display: flex;
flex-direction: column;
gap: $sp-4;
border-radius: $r-card;
background: $panel;
color: $fg;
font-size: 16px;
line-height: 24px;
font-weight: 500;
}
.fl__field { position: relative; }
.fl__box {
--ln: #{$line};
position: relative;
z-index: 1;
height: $box-h;
border-radius: $r-sm;
background: $box-bg;
box-shadow: inset 0 0 0 1.5px var(--ln), 0 0 0 calc(var(--p, 0) * 3px) rgba($accent, .3);
}
.fl__box[data-up] { --ln: #{$accent}; }
.fl__box::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: $accent;
opacity: calc(var(--p, 0) * .12);
pointer-events: none;
}
.fl__input {
position: relative;
z-index: 1;
box-sizing: border-box;
display: block;
width: 100%;
height: 100%;
margin: 0;
padding: $sp-6 $sp-3 $sp-1;
border: 0;
border-radius: inherit;
background: transparent;
color: $fg;
font-family: inherit;
font-size: 16px;
line-height: 24px;
font-weight: 500;
outline: none;
}
.fl__input:focus-visible { outline: 2px solid $accent; outline-offset: 2px; }
.fl__label {
position: absolute;
left: $sp-3;
top: 0;
z-index: 2;
line-height: $box-h;
font-size: 16px;
font-weight: 500;
color: $mute;
white-space: nowrap;
transform-origin: 0 50%;
pointer-events: none;
will-change: transform;
}
.fl__box[data-up] .fl__label { color: $accent; }
.fl__box { --ln: #{$line}; }
.fl__box[data-open] { z-index: 6; --ln: #{$accent}; }
.fl__select { padding-right: 48px; text-align: left; cursor: pointer; }
.fl__val { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fl__chev { position: absolute; right: $sp-3; top: calc(50% - #{$sp-3}); z-index: 2; width: 24px; height: 24px; color: $mute; pointer-events: none; will-change: transform; }
.fl__box[data-open] .fl__chev { color: $accent; }
.fl__menu {
position: absolute;
left: 0;
right: 0;
top: calc(100% + #{$sp-1});
z-index: 5;
box-sizing: border-box;
height: 0;
margin: 0;
padding: $sp-1;
overflow: hidden;
list-style: none;
border-radius: $r-sm;
background: $menu-bg;
box-shadow: $shadow-press;
opacity: 0;
visibility: hidden;
}
.fl__menu--up { top: auto; bottom: calc(100% + #{$sp-1}); }
.fl__opt {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-3;
border-radius: $r-xs;
font-size: 16px;
line-height: 36px;
color: $fg;
cursor: pointer;
}
.fl__opt[data-hi] { background: rgba($accent, .2); }
.fl__opt[aria-selected="true"] { font-weight: 700; }
@media (max-height: 250px) {
.fl__field:nth-child(n + 3) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.fl__label, .fl__box { will-change: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fl {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
border-radius: 16px;
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
color: #fff7e6;
font-size: 16px;
line-height: 24px;
font-weight: 500;
}
.fl__field {
position: relative;
}
.fl__box {
--ln: rgb(38.1333333333%, 36.3294117647%, 35.7960784314%);
position: relative;
z-index: 1;
height: 52px;
border-radius: 8px;
background: rgb(13.568627451%, 12.2941176471%, 14.1960784314%);
box-shadow: inset 0 0 0 1.5px var(--ln), 0 0 0 calc(var(--p, 0) * 3px) rgba(185, 160, 255, 0.3);
}
.fl__box[data-up] {
--ln: #b9a0ff;
}
.fl__box::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: #b9a0ff;
opacity: calc(var(--p, 0) * 0.12);
pointer-events: none;
}
.fl__input {
position: relative;
z-index: 1;
box-sizing: border-box;
display: block;
width: 100%;
height: 100%;
margin: 0;
padding: 24px 12px 4px;
border: 0;
border-radius: inherit;
background: transparent;
color: #fff7e6;
font-family: inherit;
font-size: 16px;
line-height: 24px;
font-weight: 500;
outline: none;
}
.fl__input:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.fl__label {
position: absolute;
left: 12px;
top: 0;
z-index: 2;
line-height: 52px;
font-size: 16px;
font-weight: 500;
color: #9a93a6;
white-space: nowrap;
transform-origin: 0 50%;
pointer-events: none;
will-change: transform;
}
.fl__box[data-up] .fl__label {
color: #b9a0ff;
}
.fl__box {
--ln: rgb(38.1333333333%, 36.3294117647%, 35.7960784314%);
}
.fl__box[data-open] {
z-index: 6;
--ln: #b9a0ff;
}
.fl__select {
padding-right: 48px;
text-align: left;
cursor: pointer;
}
.fl__val {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fl__chev {
position: absolute;
right: 12px;
top: calc(50% - 12px);
z-index: 2;
width: 24px;
height: 24px;
color: #9a93a6;
pointer-events: none;
will-change: transform;
}
.fl__box[data-open] .fl__chev {
color: #b9a0ff;
}
.fl__menu {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
z-index: 5;
box-sizing: border-box;
height: 0;
margin: 0;
padding: 4px;
overflow: hidden;
list-style: none;
border-radius: 8px;
background: rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 0;
visibility: hidden;
}
.fl__menu--up {
top: auto;
bottom: calc(100% + 4px);
}
.fl__opt {
box-sizing: border-box;
height: 36px;
padding: 0 12px;
border-radius: 4px;
font-size: 16px;
line-height: 36px;
color: #fff7e6;
cursor: pointer;
}
.fl__opt[data-hi] {
background: rgba(185, 160, 255, 0.2);
}
.fl__opt[aria-selected=true] {
font-weight: 700;
}
@media (max-height: 250px) {
.fl__field:nth-child(n+3) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fl__label, .fl__box {
will-change: auto;
}
}(function () {
const root = document.getElementById("fl");
const CLS = {};
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let last = -1;
const offs = [];
function q(sel) {
return root.querySelector(sel);
}
function qa(sel) {
const list = Array.prototype.slice.call(root.querySelectorAll(sel));
return list;
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(function () {
el.removeEventListener(type, fn);
});
}
// 좌표 해시: 같은 입력이면 같은 값 0~1. 난수 함수를 쓰지 않고 사인의 소수부만 쓴다.
function hash(x) {
const s = Math.sin(x * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
// 지수 접근: 프레임당 비율 per 만큼 목표에 다가간다. f 는 60fps 기준 프레임 수(dt * 60)라 프레임률이 달라도 같은 속도다.
function approach(x, target, per, f) {
return x + (target - x) * (1 - Math.pow(1 - per, f));
}
// 스프링 한 칸: 가속도 = -k (x - 목표) - c v. 반암시적 오일러를 1/120초 이하 부분 스텝으로 쪼갠다.
function springStep(s, target, k, c, dt) {
let left = dt;
while (left > 0) {
const h = Math.min(left, 1 / 120);
s.v += (-k * (s.x - target) - c * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
// 값이 있으면 라벨은 올라가 있어야 한다. 브라우저 자동완성이 값을 채우면 input 이벤트가 없을 수 있어 :autofill 도 함께 본다.
function isFilled(el) {
if (el.value !== "") return true;
try {
return el.matches(":-webkit-autofill");
} catch (err) {
return false;
}
}
let leaveHook = null;
function stopDemo() {
if (!demo) return;
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
resetFields();
}
function frame(ts) {
const dt = last < 0 ? 0.016 : Math.min(0.05, (ts - last) / 1000);
last = ts;
if (demo) script(ts);
step(dt, ts);
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(function () {
relayout();
});
ro.observe(root);
if (document.fonts) {
document.fonts.ready.then(function () {
relayout();
});
}
// 기법: 열림 정도 x 하나를 스프링으로 돌리고, 쉐브론 각도·목록 높이·라벨 상승을 전부 그 x 에서 파생한다. 값이 하나라 셋이 어긋날 수 없다.
const CHEV_STIFF = 160;
const CHEV_DAMP = 13;
const OPEN_DEG = 180;
const PICK_DELAY = 220;
const OPT_H = 36;
const MENU_PAD = 4;
const RISE_PX = 15;
const LABEL_SCALE = 0.74;
const fields = qa("[data-r=box]").map(function (box) {
const menu = box.querySelector("[data-r=menu]");
const opts = Array.prototype.slice.call(menu.querySelectorAll("[data-r=opt]"));
return {
box: box,
btn: box.querySelector("[data-r=btn]"),
val: box.querySelector("[data-r=val]"),
label: box.querySelector("[data-r=label]"),
chev: box.querySelector("[data-r=chev]"),
menu: menu, opts: opts,
open: false, hi: 0, sel: -1, sp: { x: 0, v: 0 }, closeIn: 0
};
});
// 이벤트 표: [시각(ms), 칸, 동작, 값]. 2초 주기로 되풀이한다.
const EVENTS = [
[200, 0, "open", 0],
[480, 0, "hi", 1],
[720, 0, "hi", 2],
[980, 0, "pick", 2],
[1250, 1, "open", 0],
[1500, 1, "hi", 1],
[1720, 1, "pick", 1]
];
let lastT = -1;
function syncAria(f) {
f.btn.setAttribute("aria-expanded", String(f.open));
if (f.open) {
f.btn.setAttribute("aria-activedescendant", f.opts[f.hi].id);
f.box.setAttribute("data-open", "");
} else {
f.btn.removeAttribute("aria-activedescendant");
f.box.removeAttribute("data-open");
}
f.opts.forEach(function (o, i) {
if (i === f.hi && f.open) o.setAttribute("data-hi", "");
else o.removeAttribute("data-hi");
o.setAttribute("aria-selected", String(i === f.sel));
});
f.val.textContent = f.sel >= 0 ? f.opts[f.sel].textContent : "";
if (reduce) {
f.sp.x = f.open ? 1 : 0;
f.sp.v = 0;
render();
}
}
function setOpen(f, v) {
f.open = v;
f.closeIn = 0;
if (v && f.sel >= 0) f.hi = f.sel;
syncAria(f);
}
function pick(f, idx) {
f.sel = idx;
f.hi = idx;
if (reduce) setOpen(f, false);
else {
f.closeIn = PICK_DELAY / 1000;
syncAria(f);
}
}
function move(f, to) {
f.hi = clamp(to, 0, f.opts.length - 1);
syncAria(f);
}
function resetFields() {
fields.forEach(function (f) {
f.sel = -1;
f.hi = 0;
setOpen(f, false);
});
}
function relayout() {
fields.forEach(syncAria);
}
function script(ts) {
const t = ts % 2000;
if (lastT < 0) lastT = t;
if (t < lastT) resetFields();
EVENTS.forEach(function (e) {
if (e[0] <= lastT || e[0] > t) return;
const f = fields[e[1]];
if (e[2] === "open") setOpen(f, true);
else if (e[2] === "hi") move(f, e[3]);
else pick(f, e[3]);
});
lastT = t;
}
function step(dt, ts) {
fields.forEach(function (f) {
springStep(f.sp, f.open ? 1 : 0, CHEV_STIFF, CHEV_DAMP, dt);
if (f.closeIn > 0) {
f.closeIn -= dt;
if (f.closeIn <= 0) setOpen(f, false);
}
});
}
function render() {
fields.forEach(function (f) {
const x = f.sp.x;
const o = clamp(x, 0, 1);
const r = f.sel >= 0 ? Math.max(1, clamp(x, 0, 1.25)) : clamp(x, 0, 1.25);
const s = 1 - (1 - LABEL_SCALE) * r;
f.label.style.transform = "translate3d(0," + (-RISE_PX * r).toFixed(2) + "px,0) scale(" + s.toFixed(3) + ")";
f.chev.style.transform = "rotate(" + (OPEN_DEG * x).toFixed(1) + "deg)";
f.box.style.setProperty("--p", clamp(r, 0, 1).toFixed(3));
const h = (OPT_H * f.opts.length + 2 * MENU_PAD) * o;
f.menu.style.height = h.toFixed(1) + "px";
f.menu.style.opacity = clamp(o * 1.6, 0, 1).toFixed(3);
f.menu.style.visibility = h < 1 ? "hidden" : "visible";
});
}
fields.forEach(function (f) {
f.opts.forEach(function (o, k) {
on(o, "pointerenter", function () {
if (f.open) move(f, k);
});
on(o, "click", function () {
stopDemo();
pick(f, k);
});
});
// 목록을 누를 때 버튼이 포커스를 잃어 닫히지 않게 한다.
on(f.menu, "mousedown", function (e) {
e.preventDefault();
});
on(f.btn, "click", function (e) {
stopDemo();
if (e.detail === 0 && f.open) pick(f, f.hi);
else setOpen(f, !f.open);
});
on(f.btn, "keydown", function (e) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
stopDemo();
if (!f.open) setOpen(f, true);
else move(f, f.hi + (e.key === "ArrowDown" ? 1 : -1));
} else if (e.key === "Escape" && f.open) {
setOpen(f, false);
}
});
on(f.btn, "blur", function () {
if (f.open) setOpen(f, false);
});
});
on(document, "pointerdown", function (e) {
fields.forEach(function (f) {
if (f.open && !f.box.contains(e.target)) setOpen(f, false);
});
});
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
})();// SelectChevronFloat - vanilla 판(09-fl-select-chevron-float) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SelectChevronFloat.module.scss";
type Spring = { x: number; v: number };
type Plan = { a: number; b: number; s: number; rate: number; text: string };
type Base = { input: HTMLInputElement | HTMLTextAreaElement; focus: boolean; up: boolean };
type F = { box: HTMLElement; btn: HTMLElement; val: HTMLElement; label: HTMLElement; chev: HTMLElement; menu: HTMLElement; opts: HTMLElement[]; open: boolean; hi: number; sel: number; sp: Spring; closeIn: number };
export default function SelectChevronFloat() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const CLS = { };
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf: number = 0;
let last: number = -1;
const offs: Array<() => void> = [];
function q(sel: string): HTMLElement {
return root.querySelector(sel) as HTMLElement;
}
function qa(sel: string): HTMLElement[] {
const list: HTMLElement[] = Array.prototype.slice.call(root.querySelectorAll(sel));
return list;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(function (): void {
el.removeEventListener(type, fn);
});
}
// 좌표 해시: 같은 입력이면 같은 값 0~1. 난수 함수를 쓰지 않고 사인의 소수부만 쓴다.
function hash(x: number): number {
const s = Math.sin(x * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
// 지수 접근: 프레임당 비율 per 만큼 목표에 다가간다. f 는 60fps 기준 프레임 수(dt * 60)라 프레임률이 달라도 같은 속도다.
function approach(x: number, target: number, per: number, f: number): number {
return x + (target - x) * (1 - Math.pow(1 - per, f));
}
// 스프링 한 칸: 가속도 = -k (x - 목표) - c v. 반암시적 오일러를 1/120초 이하 부분 스텝으로 쪼갠다.
function springStep(s: Spring, target: number, k: number, c: number, dt: number): void {
let left = dt;
while (left > 0) {
const h = Math.min(left, 1 / 120);
s.v += (-k * (s.x - target) - c * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
// 값이 있으면 라벨은 올라가 있어야 한다. 브라우저 자동완성이 값을 채우면 input 이벤트가 없을 수 있어 :autofill 도 함께 본다.
function isFilled(el: HTMLInputElement | HTMLTextAreaElement): boolean {
if (el.value !== "") return true;
try {
return el.matches(":-webkit-autofill");
} catch (err) {
return false;
}
}
let leaveHook: ((i: number) => void) | null = null;
function stopDemo(): void {
if (!demo) return;
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
resetFields();
}
function frame(ts: number): void {
const dt = last < 0 ? 0.016 : Math.min(0.05, (ts - last) / 1000);
last = ts;
if (demo) script(ts);
step(dt, ts);
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(function (): void {
relayout();
});
ro.observe(root);
if (document.fonts) {
document.fonts.ready.then(function (): void {
relayout();
});
}
// 기법: 열림 정도 x 하나를 스프링으로 돌리고, 쉐브론 각도·목록 높이·라벨 상승을 전부 그 x 에서 파생한다. 값이 하나라 셋이 어긋날 수 없다.
const CHEV_STIFF = 160;
const CHEV_DAMP = 13;
const OPEN_DEG = 180;
const PICK_DELAY = 220;
const OPT_H = 36;
const MENU_PAD = 4;
const RISE_PX = 15;
const LABEL_SCALE = 0.74;
const fields: F[] = qa("[data-r=box]").map(function (box: HTMLElement): F {
const menu = box.querySelector("[data-r=menu]") as HTMLElement;
const opts: HTMLElement[] = Array.prototype.slice.call(menu.querySelectorAll("[data-r=opt]"));
return {
box: box,
btn: box.querySelector("[data-r=btn]") as HTMLElement,
val: box.querySelector("[data-r=val]") as HTMLElement,
label: box.querySelector("[data-r=label]") as HTMLElement,
chev: box.querySelector("[data-r=chev]") as HTMLElement,
menu: menu, opts: opts,
open: false, hi: 0, sel: -1, sp: { x: 0, v: 0 }, closeIn: 0
};
});
// 이벤트 표: [시각(ms), 칸, 동작, 값]. 2초 주기로 되풀이한다.
const EVENTS: Array<[number, number, string, number]> = [
[200, 0, "open", 0],
[480, 0, "hi", 1],
[720, 0, "hi", 2],
[980, 0, "pick", 2],
[1250, 1, "open", 0],
[1500, 1, "hi", 1],
[1720, 1, "pick", 1]
];
let lastT = -1;
function syncAria(f: F): void {
f.btn.setAttribute("aria-expanded", String(f.open));
if (f.open) {
f.btn.setAttribute("aria-activedescendant", f.opts[f.hi].id);
f.box.setAttribute("data-open", "");
} else {
f.btn.removeAttribute("aria-activedescendant");
f.box.removeAttribute("data-open");
}
f.opts.forEach(function (o: HTMLElement, i: number): void {
if (i === f.hi && f.open) o.setAttribute("data-hi", "");
else o.removeAttribute("data-hi");
o.setAttribute("aria-selected", String(i === f.sel));
});
f.val.textContent = f.sel >= 0 ? f.opts[f.sel].textContent : "";
if (reduce) {
f.sp.x = f.open ? 1 : 0;
f.sp.v = 0;
render();
}
}
function setOpen(f: F, v: boolean): void {
f.open = v;
f.closeIn = 0;
if (v && f.sel >= 0) f.hi = f.sel;
syncAria(f);
}
function pick(f: F, idx: number): void {
f.sel = idx;
f.hi = idx;
if (reduce) setOpen(f, false);
else {
f.closeIn = PICK_DELAY / 1000;
syncAria(f);
}
}
function move(f: F, to: number): void {
f.hi = clamp(to, 0, f.opts.length - 1);
syncAria(f);
}
function resetFields(): void {
fields.forEach(function (f: F): void {
f.sel = -1;
f.hi = 0;
setOpen(f, false);
});
}
function relayout(): void {
fields.forEach(syncAria);
}
function script(ts: number): void {
const t = ts % 2000;
if (lastT < 0) lastT = t;
if (t < lastT) resetFields();
EVENTS.forEach(function (e: [number, number, string, number]): void {
if (e[0] <= lastT || e[0] > t) return;
const f = fields[e[1]];
if (e[2] === "open") setOpen(f, true);
else if (e[2] === "hi") move(f, e[3]);
else pick(f, e[3]);
});
lastT = t;
}
function step(dt: number, ts: number): void {
fields.forEach(function (f: F): void {
springStep(f.sp, f.open ? 1 : 0, CHEV_STIFF, CHEV_DAMP, dt);
if (f.closeIn > 0) {
f.closeIn -= dt;
if (f.closeIn <= 0) setOpen(f, false);
}
});
}
function render(): void {
fields.forEach(function (f: F): void {
const x = f.sp.x;
const o = clamp(x, 0, 1);
const r = f.sel >= 0 ? Math.max(1, clamp(x, 0, 1.25)) : clamp(x, 0, 1.25);
const s = 1 - (1 - LABEL_SCALE) * r;
f.label.style.transform = "translate3d(0," + (-RISE_PX * r).toFixed(2) + "px,0) scale(" + s.toFixed(3) + ")";
f.chev.style.transform = "rotate(" + (OPEN_DEG * x).toFixed(1) + "deg)";
f.box.style.setProperty("--p", clamp(r, 0, 1).toFixed(3));
const h = (OPT_H * f.opts.length + 2 * MENU_PAD) * o;
f.menu.style.height = h.toFixed(1) + "px";
f.menu.style.opacity = clamp(o * 1.6, 0, 1).toFixed(3);
f.menu.style.visibility = h < 1 ? "hidden" : "visible";
});
}
fields.forEach(function (f: F): void {
f.opts.forEach(function (o: HTMLElement, k: number): void {
on(o, "pointerenter", function (): void {
if (f.open) move(f, k);
});
on(o, "click", function (): void {
stopDemo();
pick(f, k);
});
});
// 목록을 누를 때 버튼이 포커스를 잃어 닫히지 않게 한다.
on(f.menu, "mousedown", function (e: MouseEvent): void {
e.preventDefault();
});
on(f.btn, "click", function (e: MouseEvent): void {
stopDemo();
if (e.detail === 0 && f.open) pick(f, f.hi);
else setOpen(f, !f.open);
});
on(f.btn, "keydown", function (e: KeyboardEvent): void {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
stopDemo();
if (!f.open) setOpen(f, true);
else move(f, f.hi + (e.key === "ArrowDown" ? 1 : -1));
} else if (e.key === "Escape" && f.open) {
setOpen(f, false);
}
});
on(f.btn, "blur", function (): void {
if (f.open) setOpen(f, false);
});
});
on(document, "pointerdown", function (e: PointerEvent): void {
fields.forEach(function (f: F): void {
if (f.open && !f.box.contains(e.target as Node)) setOpen(f, false);
});
});
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["fl"] + " is-demo"} data-demo="1">
<div className={styles["fl__field"]}>
<div className={styles["fl__box"]} data-r="box">
<button className={styles["fl__input"] + " " + styles["fl__select"]} id="c1" data-r="btn" type="button" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="m1">
<span className={styles["fl__val"]} data-r="val" />
</button>
<label className={styles["fl__label"]} htmlFor="c1" id="l1" data-r="label">도시</label>
<svg className={styles["fl__chev"]} data-r="chev" aria-hidden="true" width="24" height="24" viewBox="0 0 256 256"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
<ul className={styles["fl__menu"]} id="m1" role="listbox" aria-labelledby="l1" data-r="menu">
<li className={styles["fl__opt"]} role="option" id="m1o0" data-r="opt" aria-selected="false">서울</li>
<li className={styles["fl__opt"]} role="option" id="m1o1" data-r="opt" aria-selected="false">부산</li>
<li className={styles["fl__opt"]} role="option" id="m1o2" data-r="opt" aria-selected="false">제주</li>
</ul>
</div>
</div>
<div className={styles["fl__field"]}>
<div className={styles["fl__box"]} data-r="box">
<button className={styles["fl__input"] + " " + styles["fl__select"]} id="c2" data-r="btn" type="button" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="m2">
<span className={styles["fl__val"]} data-r="val" />
</button>
<label className={styles["fl__label"]} htmlFor="c2" id="l2" data-r="label">요일</label>
<svg className={styles["fl__chev"]} data-r="chev" aria-hidden="true" width="24" height="24" viewBox="0 0 256 256"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
<ul className={styles["fl__menu"] + " " + styles["fl__menu--up"]} id="m2" role="listbox" aria-labelledby="l2" data-r="menu">
<li className={styles["fl__opt"]} role="option" id="m2o0" data-r="opt" aria-selected="false">월요일</li>
<li className={styles["fl__opt"]} role="option" id="m2o1" data-r="opt" aria-selected="false">수요일</li>
<li className={styles["fl__opt"]} role="option" id="m2o2" data-r="opt" aria-selected="false">금요일</li>
</ul>
</div>
</div>
</div>
</main>
);
}09 シェブロンがばねで回り、選ぶとラベルが上がるセレクト欄 — 計測 累積面積 62.578% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · css floating label 9パターン — ばねと慣性でラベルが動く入力欄
このデモが属する記事
css floating label 9パターン — ばねと慣性でラベルが動く入力欄入力欄を押してからの0.3秒、css floating label がどう上がるかがフォームの第一印象を決める — 01番のばねと02番のノッチがその0.3秒を計算で満たす。九つの場面はどれも CSS の transition を使わず、J この記事のデモをすべて見る


