9 Date Range Picker UI Patterns — Copy-Paste
A date range picker UI is a control that lets someone choose a span, not just one value.
01 date-range-calendar-select
03 radio-card-plan-select-grid
05 size-quantity-chip-stepper
06 combobox-search-select-listbox
07 wheel-scroll-snap-number-picker
08 aspect-ratio-crop-preset-picker
09 dual-list-transfer-picker
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Calendar drag range select
- 02 Dual-thumb price range slider
- 03 Card-style plan radio select
- 04 Avatar icon grid picker
- 05 Size chip + quantity stepper combo
- 06 Search combobox listbox
- 07 Scroll-snap number wheel picker
- 08 Aspect-ratio crop preset picker
- 09 Dual-list transfer picker
The nine date range selector ui patterns are ordered by how many values you're choosing at once, not by popularity. 01 and 02 pick one range (start–end, min–max); 03, 04, and 05 pick one option out of several as a card, an avatar, or a chip; 06 and 07 find one item by searching or scrolling; 08 picks a ratio; 09 moves several items at once between two lists. Narrowing this picker pillar down to a few pills gives 9 Segmented Control UI Patterns, and unfolding the list downward gives 9 Select Dropdown Design Patterns.
01Calendar drag range select
What this date range picker ui design shows
Tap a start day, then an end day, on the month grid — the days between fill with a soft tint while the two edges get solid dots.

<main class="stage">
<div class="cal is-demo">
<div class="cal__head">Sep</div>
<div class="cal__grid" role="grid" aria-label="Select date range">
<div class="cal__dow" aria-hidden="true">Sun</div>
<div class="cal__dow" aria-hidden="true">Mon</div>
<div class="cal__dow" aria-hidden="true">Tue</div>
<div class="cal__dow" aria-hidden="true">Wed</div>
<div class="cal__dow" aria-hidden="true">Thu</div>
<div class="cal__dow" aria-hidden="true">Fri</div>
<div class="cal__dow" aria-hidden="true">Sat</div>
</div>
<div class="cal__days" role="rowgroup"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-pink;
@include stage($stage-paper);
.cal { position: relative; width: min(304px, 88vw); color: $subject-ink; }
.cal__head {
font-weight: 700; font-size: 13px; margin-bottom: $sp-2; color: rgba($subject-ink, .7);
}
.cal__grid, .cal__row {
display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1;
}
.cal__dow {
text-align: center; font-size: 11px; font-weight: 600; color: rgba($subject-ink, .72);
padding-bottom: $sp-1;
}
.cal__days { position: relative; margin-top: $sp-1; }
.cal__row { position: relative; }
.cal__day {
appearance: none; border: 0; background: transparent; font: inherit; font-family: inherit;
width: 100%; aspect-ratio: 1; border-radius: $r-sm; cursor: pointer;
font-size: 12px; color: $subject-ink; position: relative; z-index: 1;
transition: background-color $duration $easing, color $duration $easing, transform $duration $easing;
-webkit-tap-highlight-color: transparent;
}
.cal__day:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.cal__day.is-range { background: rgba($color, .18); }
.cal__day.is-edge {
background: $color; color: $subject-ink; font-weight: 700; transform: scale(1.06);
}
@media (prefers-reduced-motion: reduce) {
.cal__day { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cal {
position: relative;
width: min(304px, 88vw);
color: #17141a;
}
.cal__head {
font-weight: 700;
font-size: 13px;
margin-bottom: 8px;
color: rgba(23, 20, 26, 0.7);
}
.cal__grid, .cal__row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.cal__dow {
text-align: center;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
padding-bottom: 4px;
}
.cal__days {
position: relative;
margin-top: 4px;
}
.cal__row {
position: relative;
}
.cal__day {
appearance: none;
border: 0;
background: transparent;
font: inherit;
font-family: inherit;
width: 100%;
aspect-ratio: 1;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
color: #17141a;
position: relative;
z-index: 1;
transition: background-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
-webkit-tap-highlight-color: transparent;
}
.cal__day:focus-visible {
outline: 2px solid #ff9ebb;
outline-offset: 2px;
}
.cal__day.is-range {
background: rgba(255, 158, 187, 0.18);
}
.cal__day.is-edge {
background: #ff9ebb;
color: #17141a;
font-weight: 700;
transform: scale(1.06);
}
@media (prefers-reduced-motion: reduce) {
.cal__day {
transition: none;
}
}(function () {
var cal = document.querySelector('.cal');
var days = document.createElement('div');
days.className = 'cal__row';
var cells = [];
for (var d = 1; d <= 21; d++) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'cal__day'; b.textContent = d;
b.setAttribute('role', 'gridcell');
b.setAttribute('aria-selected', 'false');
b.tabIndex = d === 1 ? 0 : -1;
b.dataset.day = d;
days.appendChild(b); cells.push(b);
}
cal.querySelector('.cal__days').appendChild(days);
var start = null, end = null;
function paint() {
cells.forEach(function (c) {
var n = +c.dataset.day;
var inRange = start && end && n >= start && n <= end;
var isEdge = n === start || n === end;
c.classList.toggle('is-range', !!inRange);
c.classList.toggle('is-edge', isEdge);
c.setAttribute('aria-selected', isEdge ? 'true' : 'false');
});
}
function pick(n) {
if (start === null || (start !== null && end !== null)) { start = n; end = null; }
else { if (n < start) { end = start; start = n; } else { end = n; } }
paint();
}
cells.forEach(function (c, i) {
c.addEventListener('click', function () { stop(); pick(+c.dataset.day); });
c.addEventListener('keydown', function (e) {
var map = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: 7, ArrowUp: -7 };
if (map[e.key] !== undefined) {
e.preventDefault(); stop();
var next = cells[Math.min(cells.length - 1, Math.max(0, i + map[e.key]))];
cells.forEach(function (x) { x.tabIndex = -1; });
next.tabIndex = 0; next.focus({ preventScroll: true });
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); stop(); pick(+c.dataset.day);
}
});
});
var demoPairs = [[3, 8], [10, 15], [5, 6], [13, 19]];
var pi = 0;
function tick() { var pr = demoPairs[pi % demoPairs.length]; start = pr[0]; end = pr[1]; paint(); pi++; }
var t = setInterval(tick, 500); tick();
function stop() { cal.classList.remove('is-demo'); clearInterval(t); }
}());import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DateRangeCalendarSelect.module.scss";
export type DateRangeCalendarSelectProps = {
month?: string;
dow?: string[];
days?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--c"?: string };
export default function DateRangeCalendarSelect({
month = "Sep",
dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
days = 21,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ff9ebb",
}: DateRangeCalendarSelectProps) {
const [start, setStart] = useState<number | null>(3);
const [end, setEnd] = useState<number | null>(8);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--c": color };
function pick(n: number) {
if (start === null || (start !== null && end !== null)) {
setStart(n); setEnd(null);
} else if (n < start) {
setEnd(start); setStart(n);
} else {
setEnd(n);
}
}
return (
<div className={styles.cal} style={vars}>
<div className={styles.head}>{month}</div>
<div className={styles.grid}>
{dow.map((d) => (
<div key={d} className={styles.dow}>{d}</div>
))}
</div>
<div className={styles.row}>
{Array.from({ length: days }, (_, i) => i + 1).map((n, i) => {
const inRange = start !== null && end !== null && n >= start && n <= end;
const isEdge = n === start || n === end;
return (
<button
key={n}
type="button"
className={styles.day}
data-range={inRange}
data-edge={isEdge}
aria-selected={isEdge}
tabIndex={i === 0 ? 0 : -1}
onClick={() => pick(n)}
>
{n}
</button>
);
})}
</div>
</div>
);
}01 · Calendar drag range select — Measured area 18.911% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It fits a stay-length picker on a booking page or a vacation date range field.
.cal__day.is-range { background: rgba($color, .18); }
.cal__day.is-edge {
background: $color;
color: #fff;
font-weight: 700;
transform: scale(1.06);
}
02Dual-thumb price range slider
What happens on screen
Drag either handle — or use arrow keys — and the min/max labels update live while only the span between them fills with color.
<main class="stage">
<div class="rng is-demo">
<div class="rng__track" id="track">
<div class="rng__fill" id="fill"></div>
<button type="button" class="rng__thumb" id="thumbLo" role="slider"
aria-label="Minimum price" aria-valuemin="0" aria-valuemax="100" aria-valuenow="20" tabindex="0"></button>
<button type="button" class="rng__thumb" id="thumbHi" role="slider"
aria-label="Maximum price" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" tabindex="0"></button>
</div>
<div class="rng__labels"><span id="loLabel">2</span>0K KRW – <span id="hiLabel">7</span>0K KRW</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-ink);
.rng { width: min(280px, 84vw); color: $subject-cream; }
.rng__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-cream, .16); margin: $sp-10 0 $sp-4;
}
.rng__fill {
position: absolute; top: 0; bottom: 0; left: 20%; width: 50%;
background: $color; border-radius: $r-pill;
}
.rng__thumb {
position: absolute; top: 50%; left: 20%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $subject-cream;
box-shadow: $shadow-press; cursor: grab; touch-action: none;
transform: translate(-50%, -50%);
}
.rng__thumb:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rng__thumb:active { cursor: grabbing; }
.rng__labels { font-size: 13px; font-weight: 600; letter-spacing: .02em; }
.rng.is-demo .rng__thumb,
.rng.is-demo .rng__fill {
transition: left $dur-loop $easing, width $dur-loop $easing;
}
@media (prefers-reduced-motion: reduce) {
.rng.is-demo .rng__thumb, .rng.is-demo .rng__fill { 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);
}
.rng {
width: min(280px, 84vw);
color: #fff7e6;
}
.rng__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
margin: 40px 0 16px;
}
.rng__fill {
position: absolute;
top: 0;
bottom: 0;
left: 20%;
width: 50%;
background: #4cd4a6;
border-radius: 999px;
}
.rng__thumb {
position: absolute;
top: 50%;
left: 20%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
transform: translate(-50%, -50%);
}
.rng__thumb:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.rng__thumb:active {
cursor: grabbing;
}
.rng__labels {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
}
.rng.is-demo .rng__thumb,
.rng.is-demo .rng__fill {
transition: left 2s cubic-bezier(0.2, 1.4, 0.4, 1), width 2s cubic-bezier(0.2, 1.4, 0.4, 1);
}
@media (prefers-reduced-motion: reduce) {
.rng.is-demo .rng__thumb, .rng.is-demo .rng__fill {
transition: none;
}
}(function () {
var rng = document.querySelector('.rng'), track = document.getElementById('track'),
fill = document.getElementById('fill'), lo = document.getElementById('thumbLo'),
hi = document.getElementById('thumbHi'), loLabel = document.getElementById('loLabel'),
hiLabel = document.getElementById('hiLabel');
var v = { lo: 20, hi: 70 };
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
lo.setAttribute('aria-valuenow', v.lo); hi.setAttribute('aria-valuenow', v.hi);
loLabel.textContent = Math.round(v.lo / 10); hiLabel.textContent = Math.round(v.hi / 10);
}
function setLo(n) { v.lo = Math.max(0, Math.min(n, v.hi - 5)); paint(); }
function setHi(n) { v.hi = Math.min(100, Math.max(n, v.lo + 5)); paint(); }
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function drag(thumb, setter) {
thumb.addEventListener('pointerdown', function (e) {
stop(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 }[e.key];
if (step !== undefined) { e.preventDefault(); stop(); setter((thumb === lo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); stop(); setter(thumb === lo ? 0 : v.lo + 5); }
else if (e.key === 'End') { e.preventDefault(); stop(); setter(thumb === lo ? v.hi - 5 : 100); }
});
}
drag(lo, setLo); drag(hi, setHi);
var presets = [[20, 70], [5, 40], [45, 90], [10, 55]], pi = 0;
var t = setInterval(function () {
var p = presets[pi % presets.length]; v.lo = p[0]; v.hi = p[1]; paint(); pi++;
}, 480);
function stop() { rng.classList.remove('is-demo'); clearInterval(t); }
paint();
}());import { useState, useRef, useCallback } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./RangeSliderDualThumbFill.module.scss";
export type RangeSliderDualThumbFillProps = {
min?: number;
max?: number;
gap?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--c"?: string };
export default function RangeSliderDualThumbFill({
min = 20, max = 70, gap = 5,
duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#4cd4a6",
}: RangeSliderDualThumbFillProps) {
const [lo, setLo] = useState(min);
const [hi, setHi] = useState(max);
const trackRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--c": color };
const pctFromX = useCallback((x: number) => {
const el = trackRef.current;
if (!el) return 0;
const r = el.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}, []);
function drag(which: "lo" | "hi") {
return (e: PointerEvent<HTMLButtonElement>) => {
(e.currentTarget as any).setPointerCapture?.(e.pointerId);
function move(ev: any) {
const p = pctFromX(ev.clientX);
if (which === "lo") setLo((prev) => Math.min(p, hi - gap));
else setHi((prev) => Math.max(p, lo + gap));
}
function up() {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
}
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
}
function key(which: "lo" | "hi") {
return (e: any) => {
const step = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[e.key as string];
if (step === undefined) return;
e.preventDefault();
if (which === "lo") setLo(Math.max(0, Math.min(lo + step, hi - gap)));
else setHi(Math.min(100, Math.max(hi + step, lo + gap)));
};
}
return (
<div className={styles.rng} style={vars}>
<div className={styles.track} ref={trackRef}>
<div className={styles.fill} style={{ left: `${lo}%`, width: `${hi - lo}%` }} />
<button type="button" className={styles.thumb} style={{ left: `${lo}%` }} role="slider"
aria-label="Minimum price" aria-valuemin={0} aria-valuemax={100} aria-valuenow={lo}
onPointerDown={drag("lo")} onKeyDown={key("lo")} />
<button type="button" className={styles.thumb} style={{ left: `${hi}%` }} role="slider"
aria-label="Maximum price" aria-valuemin={0} aria-valuemax={100} aria-valuenow={hi}
onPointerDown={drag("hi")} onKeyDown={key("hi")} />
</div>
<div className={styles.labels}>{Math.round(lo / 10)}0K KRW – {Math.round(hi / 10)}0K KRW</div>
</div>
);
}02 · Dual-thumb price range slider — Measured area 3.265% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It suits a price-range filter on a shop page or a size-range filter for listings.
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function setLo(n) { v.lo = Math.max(0, Math.min(n, v.hi - 5)); paint(); }
function setHi(n) { v.hi = Math.min(100, Math.max(n, v.lo + 5)); paint(); }
03Card-style plan radio select
What moves
With :has(), only the picked card lifts with a bright border while a check badge scales into view in its corner.

<main class="stage">
<fieldset class="plans is-demo">
<legend class="sr-only">Select a plan</legend>
<label class="plan">
<input type="radio" name="plan" class="plan__radio" checked>
<span class="plan__icon" aria-hidden="true">🪶</span>
<span class="plan__name">Basic</span>
<span class="plan__price">₩0</span>
<svg class="plan__badge" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="10"/><path d="M5.5 10.5l3 3 6-6.5" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</label>
<label class="plan">
<input type="radio" name="plan" class="plan__radio">
<span class="plan__icon" aria-hidden="true">🚀</span>
<span class="plan__name">Pro</span>
<span class="plan__price">₩9,900</span>
<svg class="plan__badge" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="10"/><path d="M5.5 10.5l3 3 6-6.5" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</label>
<label class="plan">
<input type="radio" name="plan" class="plan__radio">
<span class="plan__icon" aria-hidden="true">👥</span>
<span class="plan__name">Team</span>
<span class="plan__price">₩29,900</span>
<svg class="plan__badge" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="10"/><path d="M5.5 10.5l3 3 6-6.5" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</label>
</fieldset>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.plans { border: 0; margin: 0; padding: 0; display: flex; gap: $sp-2; }
.plan {
position: relative; display: flex; flex-direction: column; align-items: center;
gap: $sp-1; width: 72px; padding: $sp-2 $sp-1; border-radius: $r-card;
border: 1px solid rgba($subject-cream, .18); background: rgba($subject-cream, .04);
cursor: pointer; -webkit-tap-highlight-color: transparent;
transition: transform $duration $easing, border-color $duration $easing, box-shadow $duration $easing;
}
.plan__radio { position: absolute; opacity: 0; width: 1px; height: 1px; }
.plan__icon { font-size: 20px; line-height: 1; }
.plan__name { font-size: 11px; font-weight: 700; color: $subject-cream; }
.plan__price { font-size: 10px; color: rgba($subject-cream, .55); }
.plan__badge {
position: absolute; top: -8px; right: -8px; width: 20px; height: 20px;
fill: $color; transform: scale(0); opacity: 0;
transition: transform $duration $ease-pop, opacity $duration $easing;
}
.plan:has(.plan__radio:checked) {
transform: translateY(-3px);
border-color: $color;
box-shadow: $shadow-raised;
}
.plan:has(.plan__radio:checked) .plan__badge { transform: scale(1); opacity: 1; }
.plan__radio:focus-visible ~ .plan__icon { outline: 2px solid $color; outline-offset: 4px; border-radius: $r-xs; }
@media (prefers-reduced-motion: reduce) {
.plan, .plan__badge { 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);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.plans {
border: 0;
margin: 0;
padding: 0;
display: flex;
gap: 8px;
}
.plan {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 72px;
padding: 8px 4px;
border-radius: 16px;
border: 1px solid rgba(255, 247, 230, 0.18);
background: rgba(255, 247, 230, 0.04);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), box-shadow 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.plan__radio {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.plan__icon {
font-size: 20px;
line-height: 1;
}
.plan__name {
font-size: 11px;
font-weight: 700;
color: #fff7e6;
}
.plan__price {
font-size: 10px;
color: rgba(255, 247, 230, 0.55);
}
.plan__badge {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
fill: #2f6df6;
transform: scale(0);
opacity: 0;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.plan:has(.plan__radio:checked) {
transform: translateY(-3px);
border-color: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.plan:has(.plan__radio:checked) .plan__badge {
transform: scale(1);
opacity: 1;
}
.plan__radio:focus-visible ~ .plan__icon {
outline: 2px solid #2f6df6;
outline-offset: 4px;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
.plan, .plan__badge {
transition: none;
}
}(function () {
var box = document.querySelector('.plans');
var radios = box.querySelectorAll('.plan__radio');
var i = 0;
var t = setInterval(function () { radios[i % radios.length].checked = true; i++; }, 700);
function stop() { box.classList.remove('is-demo'); clearInterval(t); }
box.addEventListener('pointerdown', stop);
box.addEventListener('keydown', stop);
}());import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RadioCardPlanSelectGrid.module.scss";
type Plan = { id: string; icon: string; name: string; price: string };
export type RadioCardPlanSelectGridProps = {
legend?: string;
plans?: Plan[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--c"?: string };
const DEFAULT_PLANS: Plan[] = [
{ id: "basic", icon: "🪶", name: "Basic", price: "$0" },
{ id: "pro", icon: "🚀", name: "Pro", price: "$9" },
{ id: "team", icon: "👥", name: "Team", price: "$29" },
];
export default function RadioCardPlanSelectGrid({
legend = "Select a plan",
plans = DEFAULT_PLANS,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: RadioCardPlanSelectGridProps) {
const [picked, setPicked] = useState(plans[0]?.id);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--c": color };
return (
<fieldset className={styles.plans} style={vars}>
<legend className={styles.srOnly}>{legend}</legend>
{plans.map((p) => (
<label key={p.id} className={styles.plan} data-on={picked === p.id}>
<input type="radio" name="plan" className={styles.radio} checked={picked === p.id}
onChange={() => setPicked(p.id)} />
<span className={styles.icon} aria-hidden="true">{p.icon}</span>
<span className={styles.name}>{p.name}</span>
<span className={styles.price}>{p.price}</span>
<svg className={styles.badge} viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="10" />
<path d="M5.5 10.5l3 3 6-6.5" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</label>
))}
</fieldset>
);
}03 · Card-style plan radio select — Measured area 3.631% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
It works for pricing-plan cards or shipping-method cards.
.plan:has(.plan__radio:checked) {
transform: translateY(-3px);
border-color: $color;
box-shadow: $shadow-raised;
}
.plan:has(.plan__radio:checked) .plan__badge { transform: scale(1); opacity: 1; }
04Avatar icon grid picker
What you see
Picking an avatar in the grid grows a ring in that avatar's own accent color and swaps the name in the preview above.
<main class="stage">
<div class="ava is-demo">
<div class="ava__preview"><span id="pvEmoji">🐱</span><span id="pvName">Coco</span></div>
<div class="ava__grid" role="radiogroup" aria-label="Select an avatar">
<button type="button" class="ava__item" role="radio" aria-checked="true" tabindex="0" data-emoji="🐱" data-name="Coco" data-c="pink"></button>
<button type="button" class="ava__item" role="radio" aria-checked="false" tabindex="-1" data-emoji="🐰" data-name="Tori" data-c="lilac"></button>
<button type="button" class="ava__item" role="radio" aria-checked="false" tabindex="-1" data-emoji="🐻" data-name="Bori" data-c="mint"></button>
<button type="button" class="ava__item" role="radio" aria-checked="false" tabindex="-1" data-emoji="🦊" data-name="Yeoul" data-c="sky"></button>
<button type="button" class="ava__item" role="radio" aria-checked="false" tabindex="-1" data-emoji="🐼" data-name="Panda" data-c="pink"></button>
<button type="button" class="ava__item" role="radio" aria-checked="false" tabindex="-1" data-emoji="🐨" data-name="Koal" data-c="lilac"></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-pink;
@include stage($stage-yellow);
.ava {
--sub-pink: #{$subject-pink}; --sub-mint: #{$subject-mint};
--sub-lilac: #{$subject-lilac}; --sub-sky: #{$subject-sky};
display: flex; flex-direction: column; align-items: center; gap: $sp-4;
}
.ava__preview {
display: flex; align-items: center; gap: $sp-2; font-weight: 700; font-size: 14px; color: $subject-ink;
}
.ava__preview span:first-child { font-size: 22px; }
.ava__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-3; }
.ava__item {
appearance: none; border: 2px solid transparent; background: rgba($subject-ink, .06);
width: 48px; height: 48px; border-radius: 50%; font-size: 22px; line-height: 1; cursor: pointer;
display: grid; place-items: center; -webkit-tap-highlight-color: transparent;
transition: transform $duration $easing, border-color $duration $easing, box-shadow $duration $easing;
}
.ava__item:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ava__item.is-on {
border-color: var(--c);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--c) 30%, transparent);
transform: scale(1.1);
}
@media (prefers-reduced-motion: reduce) {
.ava__item { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ava {
--sub-pink: #ff9ebb;
--sub-mint: #4cd4a6;
--sub-lilac: #b9a0ff;
--sub-sky: #6ec8ff;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.ava__preview {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 14px;
color: #17141a;
}
.ava__preview span:first-child {
font-size: 22px;
}
.ava__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.ava__item {
appearance: none;
border: 2px solid transparent;
background: rgba(23, 20, 26, 0.06);
width: 48px;
height: 48px;
border-radius: 50%;
font-size: 22px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
-webkit-tap-highlight-color: transparent;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), box-shadow 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ava__item:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ava__item.is-on {
border-color: var(--c);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--c) 30%, transparent);
transform: scale(1.1);
}
@media (prefers-reduced-motion: reduce) {
.ava__item {
transition: none;
}
}(function () {
var ava = document.querySelector('.ava');
var items = Array.prototype.slice.call(ava.querySelectorAll('.ava__item'));
items.forEach(function (b) { b.textContent = b.dataset.emoji; b.style.setProperty('--c', 'var(--sub-' + b.dataset.c + ')'); });
var pvEmoji = document.getElementById('pvEmoji'), pvName = document.getElementById('pvName');
var cur = 0;
function select(idx) {
cur = idx;
items.forEach(function (b, i) {
var on = i === idx;
b.setAttribute('aria-checked', on ? 'true' : 'false');
b.tabIndex = on ? 0 : -1;
b.classList.toggle('is-on', on);
});
pvEmoji.textContent = items[idx].dataset.emoji;
pvName.textContent = items[idx].dataset.name;
}
items.forEach(function (b, i) {
b.addEventListener('click', function () { stop(); select(i); });
b.addEventListener('keydown', function (e) {
var d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (d !== undefined) { e.preventDefault(); stop(); var n = (i + d + items.length) % items.length; select(n); items[n].focus({ preventScroll: true }); }
});
});
var t = setInterval(function () { select((cur + 1) % items.length); }, 650);
function stop() { ava.classList.remove('is-demo'); clearInterval(t); }
}());import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AvatarIconPickerGridSelect.module.scss";
type Avatar = { emoji: string; name: string; c: string };
export type AvatarIconPickerGridSelectProps = {
label?: string;
avatars?: Avatar[];
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--c"?: string };
const DEFAULT_AVATARS: Avatar[] = [
{ emoji: "🐱", name: "Coco", c: "#ff9ebb" },
{ emoji: "🐰", name: "Tori", c: "#b9a0ff" },
{ emoji: "🐻", name: "Bori", c: "#4cd4a6" },
{ emoji: "🦊", name: "Yeoul", c: "#6ec8ff" },
{ emoji: "🐼", name: "Panda", c: "#ff9ebb" },
{ emoji: "🐨", name: "Koal", c: "#b9a0ff" },
];
export default function AvatarIconPickerGridSelect({
label = "Select an avatar",
avatars = DEFAULT_AVATARS,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
}: AvatarIconPickerGridSelectProps) {
const [cur, setCur] = useState(0);
const active = avatars[cur];
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--c": active?.c ?? "#ff9ebb" };
function move(i: number, d: number) {
const n = (i + d + avatars.length) % avatars.length;
setCur(n);
document.getElementById(`avatar-item-${n}`)?.focus();
}
return (
<div className={styles.ava} style={vars}>
<div className={styles.preview}>
<span>{active?.emoji}</span><span>{active?.name}</span>
</div>
<div className={styles.grid} role="radiogroup" aria-label={label}>
{avatars.map((a, i) => (
<button
key={a.name}
id={`avatar-item-${i}`}
type="button"
className={styles.item}
role="radio"
aria-checked={cur === i}
data-on={cur === i}
style={{ "--item-c": a.c } as CSSProperties}
tabIndex={cur === i ? 0 : -1}
onClick={() => setCur(i)}
onKeyDown={(e: any) => {
const d = ({ ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } as Record<string, number>)[e.key];
if (d !== undefined) { e.preventDefault(); move(i, d); }
}}
>
{a.emoji}
</button>
))}
</div>
</div>
);
}04 · Avatar icon grid picker — Measured area 5.119% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It fits profile avatar selection or a character-select screen.
function select(idx) {
cur = idx;
items.forEach(function (b, i) {
b.setAttribute('aria-checked', i === idx ? 'true' : 'false');
b.tabIndex = i === idx ? 0 : -1;
b.classList.toggle('is-on', i === idx);
});
pvEmoji.textContent = items[idx].dataset.emoji;
pvName.textContent = items[idx].dataset.name;
}
05Size chip + quantity stepper combo
What happens on screen
Picking a size chip changes the unit price, and the +/- stepper recalculates the subtotal on the spot.

<main class="stage">
<div class="sq is-demo">
<div class="sq__sizes" role="radiogroup" aria-label="Select a size">
<label class="sq__chip"><input type="radio" name="size" value="S" class="sq__radio" data-price="19000"><span>S</span></label>
<label class="sq__chip"><input type="radio" name="size" value="M" class="sq__radio" data-price="19000" checked><span>M</span></label>
<label class="sq__chip"><input type="radio" name="size" value="L" class="sq__radio" data-price="20000"><span>L</span></label>
<label class="sq__chip"><input type="radio" name="size" value="XL" class="sq__radio" data-price="21000"><span>XL</span></label>
</div>
<div class="sq__qty">
<button type="button" class="sq__btn" id="dec" aria-label="Decrease quantity">−</button>
<span class="sq__num" id="num" aria-live="polite">1</span>
<button type="button" class="sq__btn" id="inc" aria-label="Increase quantity">+</button>
</div>
<div class="sq__total">Subtotal <strong id="total">19,000 KRW</strong></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-paper);
.sq { display: flex; flex-direction: column; align-items: center; gap: $sp-4; color: $subject-ink; }
.sq__sizes { display: flex; gap: $sp-2; }
.sq__chip { position: relative; }
.sq__radio { position: absolute; opacity: 0; width: 1px; height: 1px; }
.sq__chip span {
display: grid; place-items: center; width: 36px; height: 32px; border-radius: $r-control;
border: 1px solid rgba($subject-ink, .18); font-size: 13px; font-weight: 700; cursor: pointer;
transition: transform $duration $easing, background-color $duration $easing, color $duration $easing, border-color $duration $easing;
}
.sq__radio:checked ~ span {
background: $color; border-color: $color; color: #fff; transform: scale(1.08);
}
.sq__radio:focus-visible ~ span { outline: 2px solid $subject-ink; outline-offset: 2px; }
.sq__qty { display: flex; align-items: center; gap: $sp-3; }
.sq__btn {
appearance: none; border: 1px solid rgba($subject-ink, .2); background: #fff;
width: 30px; height: 30px; border-radius: 50%; font-size: 16px; line-height: 1; cursor: pointer;
transition: transform $duration $easing, opacity $duration $easing;
}
.sq__btn:active { transform: scale(.9); }
.sq__btn:disabled { opacity: .3; cursor: not-allowed; }
.sq__num { min-width: 24px; text-align: center; font-weight: 700; font-size: 15px; }
.sq__total { font-size: 13px; color: rgba($subject-ink, .7); }
.sq__total strong { color: $subject-ink; }
@media (prefers-reduced-motion: reduce) {
.sq__chip span, .sq__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sq {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: #17141a;
}
.sq__sizes {
display: flex;
gap: 8px;
}
.sq__chip {
position: relative;
}
.sq__radio {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.sq__chip span {
display: grid;
place-items: center;
width: 36px;
height: 32px;
border-radius: 12px;
border: 1px solid rgba(23, 20, 26, 0.18);
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.sq__radio:checked ~ span {
background: #4cd4a6;
border-color: #4cd4a6;
color: #fff;
transform: scale(1.08);
}
.sq__radio:focus-visible ~ span {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.sq__qty {
display: flex;
align-items: center;
gap: 12px;
}
.sq__btn {
appearance: none;
border: 1px solid rgba(23, 20, 26, 0.2);
background: #fff;
width: 30px;
height: 30px;
border-radius: 50%;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.sq__btn:active {
transform: scale(0.9);
}
.sq__btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.sq__num {
min-width: 24px;
text-align: center;
font-weight: 700;
font-size: 15px;
}
.sq__total {
font-size: 13px;
color: rgba(23, 20, 26, 0.7);
}
.sq__total strong {
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.sq__chip span, .sq__btn {
transition: none;
}
}(function () {
var sq = document.querySelector('.sq');
var radios = Array.prototype.slice.call(sq.querySelectorAll('.sq__radio'));
var dec = document.getElementById('dec'), inc = document.getElementById('inc'),
num = document.getElementById('num'), total = document.getElementById('total');
var qty = 1;
function price() { return +(radios.filter(function (r) { return r.checked; })[0] || radios[0]).dataset.price; }
function paint() {
num.textContent = qty;
dec.disabled = qty <= 1; inc.disabled = qty >= 9;
total.textContent = (price() * qty).toLocaleString('ko-KR') + ' KRW';
}
radios.forEach(function (r) { r.addEventListener('change', function () { stop(); paint(); }); });
dec.addEventListener('click', function () { stop(); qty = Math.max(1, qty - 1); paint(); });
inc.addEventListener('click', function () { stop(); qty = Math.min(9, qty + 1); paint(); });
var seq = [[1, 1], [3, 1], [3, 3], [0, 2], [2, 4]], si = 0;
var t = setInterval(function () {
var s = seq[si % seq.length];
radios[s[0]].checked = true; qty = s[1]; paint(); si++;
}, 600);
function stop() { sq.classList.remove('is-demo'); clearInterval(t); }
paint();
}());import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SizeQuantityChipStepper.module.scss";
type Size = { label: string; price: number };
export type SizeQuantityChipStepperProps = {
sizes?: Size[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--c"?: string };
const DEFAULT_SIZES: Size[] = [
{ label: "S", price: 19000 },
{ label: "M", price: 19000 },
{ label: "L", price: 20000 },
{ label: "XL", price: 21000 },
];
export default function SizeQuantityChipStepper({
sizes = DEFAULT_SIZES,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#4cd4a6",
}: SizeQuantityChipStepperProps) {
const [sizeIdx, setSizeIdx] = useState(1);
const [qty, setQty] = useState(1);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--c": color };
const total = sizes[sizeIdx].price * qty;
return (
<div className={styles.sq} style={vars}>
<div className={styles.sizes} role="radiogroup" aria-label="Select a size">
{sizes.map((s, i) => (
<label key={s.label} className={styles.chip}>
<input type="radio" name="size" className={styles.radio} checked={sizeIdx === i}
onChange={() => setSizeIdx(i)} />
<span data-on={sizeIdx === i}>{s.label}</span>
</label>
))}
</div>
<div className={styles.qty}>
<button type="button" className={styles.btn} aria-label="Decrease quantity"
disabled={qty <= 1} onClick={() => setQty((q) => Math.max(1, q - 1))}>−</button>
<span className={styles.num} aria-live="polite">{qty}</span>
<button type="button" className={styles.btn} aria-label="Increase quantity"
disabled={qty >= 9} onClick={() => setQty((q) => Math.min(9, q + 1))}>+</button>
</div>
<div className={styles.total}>Subtotal <strong>{total.toLocaleString("en-US")} KRW</strong></div>
</div>
);
}05 · Size chip + quantity stepper combo — Measured area 3.278% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It suits a product page where size and quantity are chosen together before adding an item to the cart.
function price() {
return +(radios.filter(function (r) { return r.checked; })[0] || radios[0]).dataset.price;
}
function paint() {
num.textContent = qty;
dec.disabled = qty <= 1; inc.disabled = qty >= 9;
total.textContent = (price() * qty).toLocaleString('en-US') + ' KRW';
}
06Search combobox listbox
What moves
Typing filters the list to options starting with those letters, and arrow-key movement updates aria-selected on the active option while focus stays on the input.

<main class="stage">
<div class="cb is-demo">
<label class="sr-only" for="cbInput">Search country</label>
<input id="cbInput" class="cb__input" type="text" role="combobox" aria-expanded="false"
aria-controls="cbList" aria-autocomplete="list" autocomplete="off" placeholder="Search country">
<ul class="cb__list" id="cbList" role="listbox" aria-label="Country list"></ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.cb { position: relative; width: min(240px, 76vw); }
.cb__input {
width: 100%; box-sizing: border-box; font: inherit; font-family: inherit; font-size: 14px;
padding: $sp-3 $sp-4; border-radius: $r-control; border: 1px solid rgba($subject-cream, .22);
background: rgba($subject-cream, .06); color: $subject-cream; outline: none;
transition: border-color $duration $easing;
}
.cb.is-open .cb__input, .cb__input:focus { border-color: $color; }
.cb__list {
position: absolute; left: 0; right: 0; top: calc(100% + #{$sp-1}); margin: 0; padding: 0 $sp-1;
list-style: none; border-radius: $r-control; background: $stage-ink;
border: 1px solid rgba($subject-cream, .16); box-shadow: $shadow-raised;
opacity: 0; max-height: 0; overflow: hidden; transform: translateY(-6px); pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing, max-height $duration $easing;
}
.cb.is-open .cb__list { opacity: 1; max-height: 132px; padding: $sp-1; transform: translateY(0); pointer-events: auto; }
.cb__opt {
padding: $sp-2 $sp-3; border-radius: $r-sm; font-size: 13px; color: $subject-cream; cursor: pointer;
transition: background-color $duration $easing;
}
.cb__opt.is-active, .cb__opt:hover { background: rgba($color, .3); }
@media (prefers-reduced-motion: reduce) {
.cb__list, .cb__input { 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);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.cb {
position: relative;
width: min(240px, 76vw);
}
.cb__input {
width: 100%;
box-sizing: border-box;
font: inherit;
font-family: inherit;
font-size: 14px;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(255, 247, 230, 0.22);
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
outline: none;
transition: border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb.is-open .cb__input, .cb__input:focus {
border-color: #2f6df6;
}
.cb__list {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
margin: 0;
padding: 0 4px;
list-style: none;
border-radius: 12px;
background: #17141a;
border: 1px solid rgba(255, 247, 230, 0.16);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
max-height: 0;
overflow: hidden;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), max-height 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb.is-open .cb__list {
opacity: 1;
max-height: 132px;
padding: 4px;
transform: translateY(0);
pointer-events: auto;
}
.cb__opt {
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
color: #fff7e6;
cursor: pointer;
transition: background-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__opt.is-active, .cb__opt:hover {
background: rgba(47, 109, 246, 0.3);
}
@media (prefers-reduced-motion: reduce) {
.cb__list, .cb__input {
transition: none;
}
}(function () {
var cb = document.querySelector('.cb'), input = document.getElementById('cbInput'), list = document.getElementById('cbList');
var all = ['South Korea', 'Japan', 'United States', 'Canada', 'France', 'Germany', 'Vietnam', 'Thailand', 'Australia', 'United Kingdom'];
var active = -1, shown = [];
function render(q) {
shown = all.filter(function (c) { return c.indexOf(q) === 0 || !q; }).slice(0, 3);
list.innerHTML = '';
shown.forEach(function (c, i) {
var li = document.createElement('li');
li.id = 'opt-' + i; li.className = 'cb__opt'; li.setAttribute('role', 'option'); li.textContent = c;
li.setAttribute('aria-selected', i === active ? 'true' : 'false');
if (i === active) li.classList.add('is-active');
li.addEventListener('click', function () { choose(i); });
list.appendChild(li);
});
}
function open() { cb.classList.add('is-open'); input.setAttribute('aria-expanded', 'true'); }
function close() { cb.classList.remove('is-open'); input.setAttribute('aria-expanded', 'false'); active = -1; }
function choose(i) { input.value = shown[i]; close(); }
input.addEventListener('input', function () { stop(); active = 0; render(input.value); open(); });
input.addEventListener('focus', function () { stop(); render(input.value); open(); });
input.addEventListener('keydown', function (e) {
stop();
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(shown.length - 1, active + 1); render(input.value); open(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(0, active - 1); render(input.value); }
else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); choose(active); }
else if (e.key === 'Escape') { close(); }
});
document.addEventListener('pointerdown', function (e) { if (!cb.contains(e.target)) close(); });
var demoQ = ['Fr', 'Fran', 'Canada', ''], qi = 0, phase = 0;
var t = setInterval(function () {
phase = (phase + 1) % 2;
if (phase === 1) { active = 0; render(demoQ[qi % demoQ.length]); open(); }
else { close(); qi++; }
}, 650);
function stop() { cb.classList.remove('is-demo'); clearInterval(t); }
render('');
}());import { useState, useMemo } from "react";
import type { CSSProperties } from "react";
import styles from "./ComboboxSearchSelectListbox.module.scss";
export type ComboboxSearchSelectListboxProps = {
options?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--c"?: string };
const DEFAULT_OPTIONS = ["South Korea", "Japan", "United States", "Canada", "France", "Germany", "Vietnam", "Thailand", "Australia", "United Kingdom"];
export default function ComboboxSearchSelectListbox({
options = DEFAULT_OPTIONS,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: ComboboxSearchSelectListboxProps) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--c": color };
const shown = useMemo(
() => options.filter((c) => !query || c.toLowerCase().startsWith(query.toLowerCase())).slice(0, 4),
[options, query]
);
function choose(i: number) {
setQuery(shown[i]); setOpen(false);
}
return (
<div className={styles.cb} style={vars} data-open={open}>
<label className={styles.srOnly} htmlFor="cb-country-input">Search country</label>
<input
id="cb-country-input"
className={styles.input}
type="text"
role="combobox"
aria-expanded={open}
aria-controls="cb-country-list"
aria-autocomplete="list"
autoComplete="off"
placeholder="Search country"
value={query}
onChange={(e: any) => { setQuery(e.target.value); setActive(0); setOpen(true); }}
onFocus={() => setOpen(true)}
onKeyDown={(e: any) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(shown.length - 1, a + 1)); setOpen(true); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); }
else if (e.key === "Enter" && shown[active]) { e.preventDefault(); choose(active); }
else if (e.key === "Escape") setOpen(false);
}}
onBlur={() => setTimeout(() => setOpen(false), 100)}
/>
<ul className={styles.list} id="cb-country-list" role="listbox" aria-label="Country list">
{shown.map((c, i) => (
<li key={c} role="option" aria-selected={i === active} data-active={i === active}
className={styles.opt} onMouseDown={() => choose(i)}>
{c}
</li>
))}
</ul>
</div>
);
}06 · Search combobox listbox — Measured area 7.848% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
It fits a country or city search-select field, or a recipient picker after a search.
function render(q) {
shown = all.filter(function (c) { return c.indexOf(q) === 0 || !q; }).slice(0, 3);
list.innerHTML = '';
shown.forEach(function (c, i) {
var li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', i === active ? 'true' : 'false');
li.textContent = c;
list.appendChild(li);
});
}
07Scroll-snap number wheel picker
What you see
Scrolling vertically enlarges and darkens whichever number lands in the center slot, and scroll-snap locks it exactly in place on release.

<main class="stage">
<div class="wheel is-demo">
<div class="wheel__mask">
<div class="wheel__track" id="track" tabindex="0" role="listbox" aria-label="Select hour">
<div class="wheel__pad" aria-hidden="true"></div>
</div>
<div class="wheel__hl" aria-hidden="true"></div>
</div>
<div class="wheel__label">hr</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-yellow);
.wheel { display: flex; flex-direction: column; align-items: center; gap: $sp-2; }
.wheel__label { font-size: 12px; font-weight: 700; color: rgba($subject-ink, .72); }
.wheel__mask {
position: relative; width: 96px; height: 120px; overflow: hidden;
border-radius: $r-card; background: rgba($subject-ink, .05);
}
.wheel__track {
height: 100%; overflow-y: scroll; scroll-snap-type: y mandatory;
scrollbar-width: none; -ms-overflow-style: none;
}
.wheel__track::-webkit-scrollbar { display: none; }
.wheel__pad { height: 40px; scroll-snap-align: center; }
.wheel__item {
height: 40px; display: grid; place-items: center; scroll-snap-align: center;
font-size: 16px; color: $subject-ink; opacity: .35; transform: scale(.8);
transition: opacity $duration $easing, transform $duration $easing;
}
.wheel__item.is-center { opacity: 1; transform: scale(1); font-weight: 700; color: color-mix(in srgb, $color 70%, $subject-ink); }
.wheel__hl {
position: absolute; left: 0; right: 0; top: 50%; height: 40px; transform: translateY(-50%);
border-top: 1px solid rgba($subject-ink, .18); border-bottom: 1px solid rgba($subject-ink, .18);
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.wheel__item { transition: none; }
.wheel__track { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wheel {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.wheel__label {
font-size: 12px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
}
.wheel__mask {
position: relative;
width: 96px;
height: 120px;
overflow: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
}
.wheel__track {
height: 100%;
overflow-y: scroll;
scroll-snap-type: y mandatory;
scrollbar-width: none;
-ms-overflow-style: none;
}
.wheel__track::-webkit-scrollbar {
display: none;
}
.wheel__pad {
height: 40px;
scroll-snap-align: center;
}
.wheel__item {
height: 40px;
display: grid;
place-items: center;
scroll-snap-align: center;
font-size: 16px;
color: #17141a;
opacity: 0.35;
transform: scale(0.8);
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.wheel__item.is-center {
opacity: 1;
transform: scale(1);
font-weight: 700;
color: color-mix(in srgb, #6ec8ff 70%, #17141a);
}
.wheel__hl {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 40px;
transform: translateY(-50%);
border-top: 1px solid rgba(23, 20, 26, 0.18);
border-bottom: 1px solid rgba(23, 20, 26, 0.18);
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.wheel__item {
transition: none;
}
.wheel__track {
scroll-behavior: auto;
}
}(function () {
var wheel = document.querySelector('.wheel'), track = document.getElementById('track');
var H = 40, N = 12;
for (var i = 1; i <= N; i++) {
var it = document.createElement('div');
it.className = 'wheel__item'; it.id = 'w-' + i; it.setAttribute('role', 'option'); it.textContent = i;
track.appendChild(it);
}
var pad2 = document.createElement('div'); pad2.className = 'wheel__pad'; pad2.setAttribute('aria-hidden', 'true');
track.appendChild(pad2);
var items = track.querySelectorAll('.wheel__item');
var centered = 0;
function mark() {
var idx = Math.round(track.scrollTop / H);
idx = Math.max(0, Math.min(N - 1, idx));
if (idx === centered) return;
centered = idx;
items.forEach(function (el, i) { el.classList.toggle('is-center', i === idx); });
track.setAttribute('aria-activedescendant', items[idx].id);
}
track.addEventListener('scroll', mark, { passive: true });
track.addEventListener('pointerdown', stop);
track.addEventListener('wheel', stop, { passive: true });
track.addEventListener('keydown', function (e) {
stop();
if (e.key === 'ArrowDown') { e.preventDefault(); track.scrollBy({ top: H, behavior: 'smooth' }); }
else if (e.key === 'ArrowUp') { e.preventDefault(); track.scrollBy({ top: -H, behavior: 'smooth' }); }
});
var di = 0;
var t = setInterval(function () {
di = (di + 1) % N;
track.scrollTo({ top: di * H, behavior: 'smooth' });
}, 520);
function stop() { wheel.classList.remove('is-demo'); clearInterval(t); }
items[0].classList.add('is-center');
}());import { useRef, useState, useCallback } from "react";
import type { CSSProperties } from "react";
import styles from "./WheelScrollSnapNumberPicker.module.scss";
export type WheelScrollSnapNumberPickerProps = {
count?: number;
itemHeight?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--c"?: string; "--h"?: string };
export default function WheelScrollSnapNumberPicker({
count = 12,
itemHeight = 40,
duration = 300,
easing = "ease-out",
color = "#6ec8ff",
}: WheelScrollSnapNumberPickerProps) {
const [centered, setCentered] = useState(0);
const trackRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--c": color, "--h": `${itemHeight}px` };
const onScroll = useCallback(() => {
const el = trackRef.current;
if (!el) return;
const idx = Math.max(0, Math.min(count - 1, Math.round(el.scrollTop / itemHeight)));
setCentered(idx);
}, [count, itemHeight]);
function onKeyDown(e: any) {
const el = trackRef.current;
if (!el) return;
if (e.key === "ArrowDown") { e.preventDefault(); el.scrollBy({ top: itemHeight, behavior: "smooth" }); }
else if (e.key === "ArrowUp") { e.preventDefault(); el.scrollBy({ top: -itemHeight, behavior: "smooth" }); }
}
return (
<div className={styles.wheel} style={vars}>
<div className={styles.mask}>
<div className={styles.track} ref={trackRef} tabIndex={0} role="listbox" aria-label="Select hour"
onScroll={onScroll} onKeyDown={onKeyDown}>
<div className={styles.pad} aria-hidden="true" />
{Array.from({ length: count }, (_, i) => i + 1).map((n, i) => (
<div key={n} className={styles.item} data-center={centered === i} role="option">{n}</div>
))}
<div className={styles.pad} aria-hidden="true" />
</div>
<div className={styles.hl} aria-hidden="true" />
</div>
<div className={styles.label}>hr</div>
</div>
);
}07 · Scroll-snap number wheel picker — Measured area 0.316% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It's built for an alarm-hour picker or a party-size wheel on a booking form.
function mark() {
var idx = Math.round(track.scrollTop / H);
idx = Math.max(0, Math.min(N - 1, idx));
if (idx === centered) return;
centered = idx;
items.forEach(function (el, i) { el.classList.toggle('is-center', i === idx); });
track.setAttribute('aria-activedescendant', items[idx].id);
}
08Aspect-ratio crop preset picker
What happens on screen
Picking a 1:1, 4:5, 16:9, or 9:16 chip smoothly morphs the preview box's aspect-ratio to match.

<main class="stage">
<div class="ar is-demo">
<div class="ar__preview"><div class="ar__box" id="box"></div></div>
<div class="ar__chips" role="radiogroup" aria-label="Select aspect ratio">
<label class="ar__chip"><input type="radio" name="ar" class="ar__radio" value="1/1" checked><span>1:1</span></label>
<label class="ar__chip"><input type="radio" name="ar" class="ar__radio" value="4/5"><span>4:5</span></label>
<label class="ar__chip"><input type="radio" name="ar" class="ar__radio" value="16/9"><span>16:9</span></label>
<label class="ar__chip"><input type="radio" name="ar" class="ar__radio" value="9/16"><span>9:16</span></label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-orange;
@include stage($stage-paper);
.ar { display: flex; flex-direction: column; align-items: center; gap: $sp-2; }
.ar__preview { height: 96px; display: grid; place-items: center; width: 100%; }
.ar__box {
aspect-ratio: 1 / 1; height: 100%; max-width: 90%;
border-radius: $r-sm; background: rgba($color, .16); border: 2px solid $color;
transition: aspect-ratio $duration $easing;
}
.ar__chips { display: flex; gap: $sp-2; }
.ar__chip { position: relative; }
.ar__radio { position: absolute; opacity: 0; width: 1px; height: 1px; }
.ar__chip span {
display: grid; place-items: center; padding: $sp-2 $sp-3; border-radius: $r-pill;
border: 1px solid rgba($subject-ink, .18); font-size: 12px; font-weight: 700; color: $subject-ink;
cursor: pointer; transition: background-color $duration $easing, color $duration $easing, transform $duration $easing;
}
.ar__radio:checked ~ span { background: $color; border-color: $color; color: #fff; transform: scale(1.05); }
.ar__radio:focus-visible ~ span { outline: 2px solid $subject-ink; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.ar__box, .ar__chip span { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ar {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.ar__preview {
height: 96px;
display: grid;
place-items: center;
width: 100%;
}
.ar__box {
aspect-ratio: 1/1;
height: 100%;
max-width: 90%;
border-radius: 8px;
background: rgba(255, 77, 31, 0.16);
border: 2px solid #ff4d1f;
transition: aspect-ratio 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ar__chips {
display: flex;
gap: 8px;
}
.ar__chip {
position: relative;
}
.ar__radio {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.ar__chip span {
display: grid;
place-items: center;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(23, 20, 26, 0.18);
font-size: 12px;
font-weight: 700;
color: #17141a;
cursor: pointer;
transition: background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ar__radio:checked ~ span {
background: #ff4d1f;
border-color: #ff4d1f;
color: #fff;
transform: scale(1.05);
}
.ar__radio:focus-visible ~ span {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.ar__box, .ar__chip span {
transition: none;
}
}(function () {
var ar = document.querySelector('.ar'), box = document.getElementById('box');
var radios = Array.prototype.slice.call(ar.querySelectorAll('.ar__radio'));
function apply(v) { box.style.aspectRatio = v; }
radios.forEach(function (r) { r.addEventListener('change', function () { stop(); apply(r.value); }); });
var seq = ['1/1', '4/5', '16/9', '9/16'], si = 0;
var t = setInterval(function () {
si = (si + 1) % seq.length;
radios[si].checked = true; apply(seq[si]);
}, 700);
function stop() { ar.classList.remove('is-demo'); clearInterval(t); }
apply('1/1');
}());import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AspectRatioCropPresetPicker.module.scss";
export type AspectRatioCropPresetPickerProps = {
ratios?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--c"?: string };
export default function AspectRatioCropPresetPicker({
ratios = ["1/1", "4/5", "16/9", "9/16"],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ff4d1f",
}: AspectRatioCropPresetPickerProps) {
const [ratio, setRatio] = useState(ratios[0]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--c": color };
return (
<div className={styles.ar} style={vars}>
<div className={styles.preview}>
<div className={styles.box} style={{ aspectRatio: ratio }} />
</div>
<div className={styles.chips} role="radiogroup" aria-label="Select aspect ratio">
{ratios.map((r) => (
<label key={r} className={styles.chip}>
<input type="radio" name="ratio" className={styles.radio} checked={ratio === r}
onChange={() => setRatio(r)} />
<span data-on={ratio === r}>{r.replace("/", ":")}</span>
</label>
))}
</div>
</div>
);
}08 · Aspect-ratio crop preset picker — Measured area 12.704% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It fits cropping a profile photo or setting an upload ratio for Reels or Shorts.
function apply(v) { box.style.aspectRatio = v; }
radios.forEach(function (r) {
r.addEventListener('change', function () { apply(r.value); });
});
.ar__box {
aspect-ratio: 1 / 1;
height: 100%;
border: 2px solid $color;
transition: aspect-ratio $duration $easing;
}
.ar__radio:checked ~ span { background: $color; color: #fff; }
09Dual-list transfer picker
What moves
Select items in the left list and press the arrow button to move them to the right list; arrow keys roam within each list.

<main class="stage">
<div class="tf is-demo">
<div class="tf__col">
<div class="tf__h">Available</div>
<ul class="tf__list" id="avail" role="listbox" aria-label="Available items" aria-multiselectable="true"></ul>
</div>
<div class="tf__mid">
<button type="button" class="tf__btn" id="toRight" aria-label="Move to selected">→</button>
<button type="button" class="tf__btn" id="toLeft" aria-label="Move to available">←</button>
</div>
<div class="tf__col">
<div class="tf__h">Selected</div>
<ul class="tf__list" id="chosen" role="listbox" aria-label="Selected items" aria-multiselectable="true"></ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.tf { display: flex; align-items: center; gap: $sp-2; color: $subject-cream; }
.tf__col { width: 96px; }
.tf__h { font-size: 11px; font-weight: 700; color: rgba($subject-cream, .5); margin-bottom: $sp-1; }
.tf__list {
list-style: none; margin: 0; padding: $sp-1; height: 128px; overflow: auto;
border: 1px solid rgba($subject-cream, .16); border-radius: $r-sm; background: rgba($subject-cream, .04);
}
.tf__item {
padding: $sp-1 $sp-2; border-radius: $r-xs; font-size: 12px; cursor: pointer;
transition: background-color $duration $easing, transform $duration $easing;
}
.tf__item.is-sel { background: rgba($color, .35); transform: translateX(2px); }
.tf__item:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.tf__mid { display: flex; flex-direction: column; gap: $sp-2; }
.tf__btn {
appearance: none; border: 1px solid rgba($subject-cream, .2); background: rgba($subject-cream, .06);
color: $subject-cream; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 13px;
transition: transform $duration $easing, background-color $duration $easing;
}
.tf__btn:active { transform: scale(.88); }
@media (prefers-reduced-motion: reduce) {
.tf__item, .tf__btn { 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);
}
.tf {
display: flex;
align-items: center;
gap: 8px;
color: #fff7e6;
}
.tf__col {
width: 96px;
}
.tf__h {
font-size: 11px;
font-weight: 700;
color: rgba(255, 247, 230, 0.5);
margin-bottom: 4px;
}
.tf__list {
list-style: none;
margin: 0;
padding: 4px;
height: 128px;
overflow: auto;
border: 1px solid rgba(255, 247, 230, 0.16);
border-radius: 8px;
background: rgba(255, 247, 230, 0.04);
}
.tf__item {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: background-color 300ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tf__item.is-sel {
background: rgba(47, 109, 246, 0.35);
transform: translateX(2px);
}
.tf__item:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.tf__mid {
display: flex;
flex-direction: column;
gap: 8px;
}
.tf__btn {
appearance: none;
border: 1px solid rgba(255, 247, 230, 0.2);
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 13px;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), background-color 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tf__btn:active {
transform: scale(0.88);
}
@media (prefers-reduced-motion: reduce) {
.tf__item, .tf__btn {
transition: none;
}
}(function () {
var tf = document.querySelector('.tf');
var availEl = document.getElementById('avail'), chosenEl = document.getElementById('chosen');
var avail = ['CSS', 'JS', 'HTML', 'SCSS', 'React'], chosen = [];
var selA = {}, selC = {};
function row(name, sel, i) {
var li = document.createElement('li');
li.className = 'tf__item'; li.setAttribute('role', 'option'); li.textContent = name;
li.tabIndex = i === 0 ? 0 : -1;
li.setAttribute('aria-selected', sel[name] ? 'true' : 'false');
li.classList.toggle('is-sel', !!sel[name]);
return li;
}
function render() {
availEl.innerHTML = ''; chosenEl.innerHTML = '';
avail.forEach(function (n, i) { availEl.appendChild(bind(row(n, selA, i), n, selA, availEl)); });
chosen.forEach(function (n, i) { chosenEl.appendChild(bind(row(n, selC, i), n, selC, chosenEl)); });
}
function bind(li, name, sel, list) {
li.addEventListener('click', function () { stop(); sel[name] = !sel[name]; render(); });
li.addEventListener('keydown', function (e) {
stop();
var items = Array.prototype.slice.call(list.children);
var i = items.indexOf(li);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
var n = items[Math.max(0, Math.min(items.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))];
items.forEach(function (x) { x.tabIndex = -1; }); n.tabIndex = 0; n.focus({ preventScroll: true });
} else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); sel[name] = !sel[name]; render(); }
});
return li;
}
function move(from, to, sel) {
var keep = [], moved = [];
from.forEach(function (n) { (sel[n] ? moved : keep).push(n); });
from.length = 0; Array.prototype.push.apply(from, keep);
Array.prototype.push.apply(to, moved);
Object.keys(sel).forEach(function (k) { delete sel[k]; });
render();
}
document.getElementById('toRight').addEventListener('click', function () { stop(); move(avail, chosen, selA); });
document.getElementById('toLeft').addEventListener('click', function () { stop(); move(chosen, avail, selC); });
var t = setInterval(function () {
if (avail.length) { selA[avail[0]] = true; move(avail, chosen, selA); }
else { selC[chosen[0]] = true; move(chosen, avail, selC); }
}, 380);
function stop() { tf.classList.remove('is-demo'); clearInterval(t); }
render();
}());import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DualListTransferPicker.module.scss";
export type DualListTransferPickerProps = {
items?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--c"?: string };
export default function DualListTransferPicker({
items = ["CSS", "JS", "HTML", "SCSS", "React"],
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: DualListTransferPickerProps) {
const [avail, setAvail] = useState(items);
const [chosen, setChosen] = useState<string[]>([]);
const [selA, setSelA] = useState<Record<string, boolean>>({});
const [selC, setSelC] = useState<Record<string, boolean>>({});
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--c": color };
function toggle(name: string, sel: Record<string, boolean>, setSel: (v: Record<string, boolean>) => void) {
setSel({ ...sel, [name]: !sel[name] });
}
function move(from: string[], setFrom: (v: string[]) => void, to: string[], setTo: (v: string[]) => void,
sel: Record<string, boolean>, setSel: (v: Record<string, boolean>) => void) {
const moved = from.filter((n) => sel[n]);
setFrom(from.filter((n) => !sel[n]));
setTo([...to, ...moved]);
setSel({});
}
function list(names: string[], sel: Record<string, boolean>, setSel: (v: Record<string, boolean>) => void, label: string) {
return (
<ul className={styles.list} role="listbox" aria-label={label} aria-multiselectable="true">
{names.map((n, i) => (
<li key={n} role="option" aria-selected={!!sel[n]} data-sel={!!sel[n]} className={styles.item}
tabIndex={i === 0 ? 0 : -1}
onClick={() => toggle(n, sel, setSel)}
onKeyDown={(e: any) => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); toggle(n, sel, setSel); } }}>
{n}
</li>
))}
</ul>
);
}
return (
<div className={styles.tf} style={vars}>
<div className={styles.col}>
<div className={styles.h}>Available</div>
{list(avail, selA, (v) => setSelA(v), "Available items")}
</div>
<div className={styles.mid}>
<button type="button" className={styles.btn} aria-label="Move to selected"
onClick={() => move(avail, setAvail, chosen, setChosen, selA, setSelA)}>→</button>
<button type="button" className={styles.btn} aria-label="Move to available"
onClick={() => move(chosen, setChosen, avail, setAvail, selC, setSelC)}>←</button>
</div>
<div className={styles.col}>
<div className={styles.h}>Selected</div>
{list(chosen, selC, (v) => setSelC(v), "Selected items")}
</div>
</div>
);
}09 · Dual-list transfer picker — Measured area 1.42% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
It's built for assigning permissions or roles, or building an email recipient group.
function move(from, to, sel) {
var keep = [], moved = [];
from.forEach(function (n) { (sel[n] ? moved : keep).push(n); });
from.length = 0; Array.prototype.push.apply(from, keep);
Array.prototype.push.apply(to, moved);
Object.keys(sel).forEach(function (k) { delete sel[k]; });
render();
}
Where it breaks — a hidden decorative layer still counts toward scroll size
Building 01, I first added a purely decorative sweep bar behind the calendar — a ::before that only used translateX and was meant to loop on its own, separate from the real click logic. Once it moved translateX(366%), the bar sat visually off the stage, but document.documentElement.scrollWidth still grew by that distance, so both the 480×300 render and the 320px phone check reported overflow. opacity: 0 and pointer-events: none hide a layer from view, but its box and travel distance are still measured. The fix was to drop the decorative layer and drive the loop with the real .is-range/.is-edge state changes instead. 06's search list hit a related trap: a closed list panel that's only hidden with opacity keeps its full height in the box tree, so the initial render still overflows — only max-height: 0; overflow: hidden; actually collapses a closed panel. 03's :has() only runs on Chrome/Edge 105+, Safari 15.4+, Firefox 121+; below that, the card just doesn't lift, but the underlying radio selection still works. The avatar pieces that show who got picked are gathered in Avatar UI Design. The zip password for all nine files is uv8qjazc.
Accessibility
03, 05, and 08 hide a real <input type="radio"> visually only, so a screen reader still reads it as a radio and arrow keys still move between options, while 01 uses role="gridcell" day buttons with aria-selected and its own arrow-key grid navigation instead of a native input. 02, 04, 07, and 09 are custom controls, so each carries role="slider", role="radiogroup", or role="listbox" along with aria-valuenow, aria-checked, or aria-selected, updated on every state change. 06 announces the highlighted option through aria-selected on the active role="option" item without moving focus from the input, and 05's quantity is aria-live="polite" so a new count is read quietly after every tap. Under prefers-reduced-motion: reduce, all nine drop their transitions, and the selected state — a check, a ring, a badge — stays in its final shape instead of disappearing.
@media (prefers-reduced-motion: reduce) {
.cal__day, .plan, .ava__item, .ar__box { transition: none; }
}
For more single components in the picker family, see 9 Segmented Control UI Patterns; the select-and-dropdown half of the same family is in 9 Select Dropdown Design Patterns.
FAQ
How is this date range picker html different from two separate date fields?
Two date fields take a start and an end value independently, but 01 lets you tap both on one grid so the days between fill in immediately — you see the length of the stay, not just two dates. That matters most on booking pages where the span itself is the thing being decided. Choosing a clock time and a duration rather than a date is handled by 9 time picker ui patterns.
What happens if the two thumbs on the price slider get pushed together?
02's setLo and setHi always keep the minimum at least 5 units below the maximum. Push one handle toward the other and the other handle gets nudged along by that minimum gap, so the two thumbs never overlap or swap order.
Does the scroll-snap wheel picker work without a mouse wheel?
Yes — 07's track has scroll-snap-type: y mandatory, so a touch swipe settles naturally, and focusing the track and pressing an arrow key calls scrollBy({ top, behavior: 'smooth' }) to move one row at a time. The mouse wheel is just one more way to trigger the same scroll.









