01Spring-open base select
Clicking the button really opens the list, scaling out from its top edge with a spring, and picking an option rewrites the button label.
Best forWhen the grey native select clashes with your design and you want the opening motion too, not just the skin
transform-originaria-expandedEscape

<main class="stage">
<div class="ss is-demo">
<span class="ss__label" id="ss-label">Shipping method</span>
<button class="ss__btn" type="button" id="ss-btn" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="ss-list" aria-labelledby="ss-label ss-value">
<span class="ss__value" id="ss-value">Standard</span>
<span class="ss__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><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></span>
</button>
<ul class="ss__list" id="ss-list" role="listbox" aria-labelledby="ss-label" tabindex="-1">
<li class="ss__opt is-sel" id="ss-o1" role="option" aria-selected="true"><span>Standard</span><span class="ss__tick" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span></li>
<li class="ss__opt" id="ss-o2" role="option" aria-selected="false"><span>Dawn delivery</span><span class="ss__tick" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span></li>
<li class="ss__opt" id="ss-o3" role="option" aria-selected="false"><span>Same day</span><span class="ss__tick" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span></li>
<li class="ss__opt" id="ss-o4" role="option" aria-selected="false"><span>Store pickup</span><span class="ss__tick" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.ss {
position: relative;
box-sizing: border-box;
width: min(232px, 84vw);
// 열린 목록은 absolute 라 높이에 안 잡힌다 — 그만큼 아래를 비워 둬야 무대 가운데 정렬이 맞는다
padding-bottom: 116px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.ss__label {
display: block;
font-size: 10px; line-height: 12px; font-weight: 700;
letter-spacing: .04em;
color: rgba(255, 255, 255, .6);
margin: 0 0 $sp-1;
}
.ss__btn {
box-sizing: border-box;
width: 100%; height: 32px;
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
padding: 0 $sp-3;
border: 1px solid rgba(255, 255, 255, .14);
background: rgba(255, 255, 255, .06);
border-radius: $r-control;
color: #fff;
font-family: inherit;
font-size: 12px; font-weight: 600;
cursor: pointer;
transition: border-color $duration $easing, background $duration $easing;
}
.ss__btn:hover { background: rgba(255, 255, 255, .1); }
.ss.is-open .ss__btn { border-color: $color; }
.ss__caret { display: inline-flex; color: rgba(255, 255, 255, .55); transition: transform $duration $easing; }
.ss.is-open .ss__caret { transform: rotate(180deg); }
.ss__list {
position: absolute;
top: 52px; left: 0; right: 0;
box-sizing: border-box;
margin: 0; padding: $sp-1;
list-style: none;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
transform-origin: top center; // 규칙8: 버튼 아래변에서 펼침
transform: translateY(-6px) scaleY(.72);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.ss.is-open .ss__list { transform: translateY(0) scaleY(1); opacity: 1; pointer-events: auto; }
.ss__list:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ss__opt {
box-sizing: border-box;
height: 26px;
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
cursor: pointer;
}
.ss__opt.is-active { background: rgba(47, 109, 246, .12); }
.ss__tick { display: inline-flex; color: $color; opacity: 0; }
.ss__opt.is-sel .ss__tick { opacity: 1; }
// ── 자동 루프: 열림 → 유지 → 닫힘. 끝 프레임이 첫 프레임과 같아 이어 붙어도 튀지 않는다 ──
.ss.is-demo .ss__list { animation: ss-open $dur-loop $easing infinite; }
@keyframes ss-open {
0%, 100% { opacity: 0; transform: translateY(-6px) scaleY(.72); }
22% { opacity: 1; transform: translateY(0) scaleY(1); }
78% { opacity: 1; transform: translateY(0) scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.ss.is-demo .ss__list { animation: none !important; }
.ss__list, .ss__btn, .ss__caret { transition: none; }
}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);
}
.ss {
position: relative;
box-sizing: border-box;
width: min(232px, 84vw);
padding-bottom: 116px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ss__label {
display: block;
font-size: 10px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 4px;
}
.ss__btn {
box-sizing: border-box;
width: 100%;
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
border-radius: 12px;
color: #fff;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ss__btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.ss.is-open .ss__btn {
border-color: #2f6df6;
}
.ss__caret {
display: inline-flex;
color: rgba(255, 255, 255, 0.55);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ss.is-open .ss__caret {
transform: rotate(180deg);
}
.ss__list {
position: absolute;
top: 52px;
left: 0;
right: 0;
box-sizing: border-box;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top center;
transform: translateY(-6px) scaleY(0.72);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ss.is-open .ss__list {
transform: translateY(0) scaleY(1);
opacity: 1;
pointer-events: auto;
}
.ss__list:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ss__opt {
box-sizing: border-box;
height: 26px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.ss__opt.is-active {
background: rgba(47, 109, 246, 0.12);
}
.ss__tick {
display: inline-flex;
color: #2f6df6;
opacity: 0;
}
.ss__opt.is-sel .ss__tick {
opacity: 1;
}
.ss.is-demo .ss__list {
animation: ss-open 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes ss-open {
0%, 100% {
opacity: 0;
transform: translateY(-6px) scaleY(0.72);
}
22% {
opacity: 1;
transform: translateY(0) scaleY(1);
}
78% {
opacity: 1;
transform: translateY(0) scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.ss.is-demo .ss__list {
animation: none !important;
}
.ss__list, .ss__btn, .ss__caret {
transition: none;
}
}// 실제 동작: 버튼으로 열고 닫고, 화살표로 옮기고, Enter 로 고르고, Escape 로 닫는다.
var root = document.querySelector('.ss');
var btn = document.getElementById('ss-btn');
var list = document.getElementById('ss-list');
var value = document.getElementById('ss-value');
var opts = [].slice.call(list.querySelectorAll('.ss__opt'));
var active = 0;
function paint() {
opts.forEach(function (o, i) { o.classList.toggle('is-active', i === active); });
list.setAttribute('aria-activedescendant', opts[active].id);
}
function open(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', String(on));
if (on) { paint(); list.focus({ preventScroll: true }); }
}
function pick(i) {
opts.forEach(function (o, k) {
o.classList.toggle('is-sel', k === i);
o.setAttribute('aria-selected', String(k === i));
});
value.textContent = opts[i].firstElementChild.textContent;
open(false);
btn.focus({ preventScroll: true });
}
btn.addEventListener('click', function () { open(!root.classList.contains('is-open')); });
opts.forEach(function (o, i) {
o.addEventListener('click', function () { pick(i); });
o.addEventListener('mousemove', function () { active = i; paint(); });
});
list.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { active = Math.min(active + 1, opts.length - 1); paint(); e.preventDefault(); }
else if (e.key === 'ArrowUp') { active = Math.max(active - 1, 0); paint(); e.preventDefault(); }
else if (e.key === 'Enter' || e.key === ' ') { pick(active); e.preventDefault(); }
else if (e.key === 'Escape') { open(false); btn.focus({ preventScroll: true }); }
});
document.addEventListener('click', function (e) { if (!e.target.closest('.ss')) open(false); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpringSelect.module.scss";
export type SpringSelectProps = {
label?: string;
options?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
type ClickEvt = { stopPropagation: () => void };
export default function SpringSelect({
label = "Shipping method",
options = ["Standard", "Dawn delivery", "Same day", "Store pickup"],
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: SpringSelectProps) {
const [open, setOpen] = useState(false);
const [picked, setPicked] = useState(0);
const [active, setActive] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("click", onDocClick);
return () => document.removeEventListener("click", onDocClick);
}, []);
useEffect(() => {
if (open) listRef.current?.focus({ preventScroll: true });
}, [open]);
function pick(i: number) {
setPicked(i);
setOpen(false);
btnRef.current?.focus({ preventScroll: true });
}
return (
<div className={`${styles.ss} ${open ? styles.isOpen : ""}`} style={vars} ref={rootRef}>
<span className={styles.label} id="ss-label">{label}</span>
<button
ref={btnRef}
type="button"
className={styles.btn}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls="ss-list"
onClick={() => setOpen((o) => !o)}
>
<span>{options[picked]}</span>
<span className={styles.caret} aria-hidden="true">▾</span>
</button>
<ul
ref={listRef}
id="ss-list"
className={styles.list}
role="listbox"
aria-labelledby="ss-label"
aria-activedescendant={`ss-o${active}`}
tabIndex={-1}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown") { setActive((a) => Math.min(a + 1, options.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setActive((a) => Math.max(a - 1, 0)); e.preventDefault(); }
else if (e.key === "Enter" || e.key === " ") { pick(active); e.preventDefault(); }
else if (e.key === "Escape") { setOpen(false); btnRef.current?.focus({ preventScroll: true }); }
}}
>
{options.map((o, i) => (
<li
key={o}
id={`ss-o${i}`}
className={`${styles.opt} ${i === active ? styles.isActive : ""}`}
role="option"
aria-selected={i === picked}
onMouseMove={() => setActive(i)}
onClick={() => pick(i)}
>
<span>{o}</span>
<span className={styles.tick} aria-hidden="true">{i === picked ? "●" : ""}</span>
</li>
))}
</ul>
</div>
);
}01 Spring-open base select — Measured area 19.057% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · 9 Select Dropdown Design Patterns That Really Open
The post this demo lives in
9 Select Dropdown Design Patterns That Really OpenA hand-built select dropdown design replaces the gray box the browser draws for <select> with a button and a list of you See all demos in this post


