カレンダー 選択 UI 9選 — コピペOK
カレンダー 選択 UI は、値一つではなく開始日と終了日のような範囲をまとめて選ばせる部品です。セグメントコントロールに続き、範囲・アバター・検索・ホイール・比率まで、実際の実装で詰まりやすい選択UI9種を、コードとともにまとめました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 カレンダー範囲ドラッグ選択
- 02 デュアルサム価格帯スライダー
- 03 カード型プランラジオ選択
- 04 アバターアイコングリッドピッカー
- 05 サイズチップ+数量ステッパーの組み合わせ
- 06 検索コンボボックスリストボックス
- 07 スクロールスナップ数字ホイールピッカー
- 08 アスペクト比クロッププリセットピッカー
- 09 左右移動デュアルリストピッカー
9つの カレンダー 選択 フォームは人気順ではなく、一度に選ぶ値の数が増える順番で並べました。01・02は一つの範囲(開始〜終了、最小〜最大)を選び、03・04・05はカード・アバター・チップで一つの選択肢を選び、06・07は検索やスクロールで一つの項目を探し、08は比率を、09は複数の項目を一度に別リストへ移します。同じpicker柱で選択肢をピル数個に絞ったのがセグメントコントロールUI 9選で、一覧を下へ開いて選ぶのがセレクトボックス デザイン css 9選です。
01カレンダー範囲ドラッグ選択
日付選択 カレンダーとして何が見えるか
月のグリッドで開始日を選び、続けて終了日を選ぶと、その間の日付が薄く塗られ、両端だけ濃い点になります。

<main class="stage">
<div class="cal is-demo">
<div class="cal__head">9月</div>
<div class="cal__grid" role="grid" aria-label="日付範囲を選択">
<div class="cal__dow" aria-hidden="true">日</div>
<div class="cal__dow" aria-hidden="true">月</div>
<div class="cal__dow" aria-hidden="true">火</div>
<div class="cal__dow" aria-hidden="true">水</div>
<div class="cal__dow" aria-hidden="true">木</div>
<div class="cal__dow" aria-hidden="true">金</div>
<div class="cal__dow" aria-hidden="true">土</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 · カレンダー範囲ドラッグ選択 — 計測 累積面積 18.911% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
宿泊予約や休暇申請の期間選択に使えます。
.cal__day.is-range { background: rgba($color, .18); }
.cal__day.is-edge {
background: $color;
color: #fff;
font-weight: 700;
transform: scale(1.06);
}
02デュアルサム価格帯スライダー
画面で起きること
二つのつまみをドラッグ、または矢印キーで動かすと最小・最大の値が更新され、その間のトラックだけ色で埋まります。
<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="最低価格" aria-valuemin="0" aria-valuemax="100" aria-valuenow="20" tabindex="0"></button>
<button type="button" class="rng__thumb" id="thumbHi" role="slider"
aria-label="最高価格" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" tabindex="0"></button>
</div>
<div class="rng__labels"><span id="loLabel">2</span>万円 – <span id="hiLabel">7</span>万円</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 · デュアルサム価格帯スライダー — 計測 累積面積 3.265% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
ECサイトの価格帯フィルターや、面積・坪数の範囲フィルターに向いています。
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(); }
03カード型プランラジオ選択
どんな動きか
:has()で選んだカードだけが少し浮き上がって枠が明るくなり、右上にチェックバッジがスケールで飛び出します。

<main class="stage">
<fieldset class="plans is-demo">
<legend class="sr-only">プランを選択</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">ベーシック</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">プロ</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">チーム</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 · カード型プランラジオ選択 — 計測 累積面積 3.631% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
料金プランカードや配送方法カードの選択に使えます。
.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; }
04アバターアイコングリッドピッカー
何が見えるか
グリッドのアバターを一つ選ぶと、そのアバター固有の色でリングが大きくなり、上のプレビュー名も一緒に変わります。
<main class="stage">
<div class="ava is-demo">
<div class="ava__preview"><span id="pvEmoji">🐱</span><span id="pvName">ココ</span></div>
<div class="ava__grid" role="radiogroup" aria-label="アバターを選択">
<button type="button" class="ava__item" role="radio" aria-checked="true" tabindex="0" data-emoji="🐱" data-name="ココ" data-c="pink"></button>
<button type="button" class="ava__item" role="radio" aria-checked="false" tabindex="-1" data-emoji="🐰" data-name="トリ" data-c="lilac"></button>
<button type="button" class="ava__item" role="radio" aria-checked="false" tabindex="-1" data-emoji="🐻" data-name="ボリ" data-c="mint"></button>
<button type="button" class="ava__item" role="radio" aria-checked="false" tabindex="-1" data-emoji="🦊" data-name="ヨウル" data-c="sky"></button>
<button type="button" class="ava__item" role="radio" aria-checked="false" tabindex="-1" data-emoji="🐼" data-name="パンダ" data-c="pink"></button>
<button type="button" class="ava__item" role="radio" aria-checked="false" tabindex="-1" data-emoji="🐨" data-name="コアル" 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 · アバターアイコングリッドピッカー — 計測 累積面積 5.119% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
プロフィールアバターの選択やキャラクター選択画面に向いています。
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;
}
05サイズチップ+数量ステッパーの組み合わせ
画面で起きること
サイズチップを選ぶと単価が変わり、+/-ステッパーで数量を変えるとその場で小計が再計算されます。

<main class="stage">
<div class="sq is-demo">
<div class="sq__sizes" role="radiogroup" aria-label="サイズを選択">
<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="数量を減らす">−</button>
<span class="sq__num" id="num" aria-live="polite">1</span>
<button type="button" class="sq__btn" id="inc" aria-label="数量を増やす">+</button>
</div>
<div class="sq__total">小計 <strong id="total">19,000ウォン</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') + 'ウォン';
}
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 · サイズチップ+数量ステッパーの組み合わせ — 計測 累積面積 3.278% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
ECサイトでサイズと数量を同じ画面で決める商品オプションに使えます。
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('ja-JP') + '円';
}
06検索コンボボックスリストボックス
どんな動きか
入力欄に文字を打つと、その文字で始まる項目だけに絞られてリストが開き、矢印キーで動いた項目はaria-selectedで示されます。

<main class="stage">
<div class="cb is-demo">
<label class="sr-only" for="cbInput">国を検索</label>
<input id="cbInput" class="cb__input" type="text" role="combobox" aria-expanded="false"
aria-controls="cbList" aria-autocomplete="list" autocomplete="off" placeholder="国を検索">
<ul class="cb__list" id="cbList" role="listbox" aria-label="国リスト"></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 = ['韓国', '日本', 'アメリカ', 'カナダ', 'フランス', 'ドイツ', 'ベトナム', 'タイ', 'オーストラリア', 'イギリス'];
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 = ['フ', 'フラ', 'カナダ', ''], 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 · 検索コンボボックスリストボックス — 計測 累積面積 7.848% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
国・都市の検索選択欄や、宛先を検索して選ぶ場面に向いています。
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);
});
}
07スクロールスナップ数字ホイールピッカー
何が見えるか
縦にスクロールすると中央の枠に来た数字だけ大きく濃くなり、指を離す瞬間scroll-snapがその数字にぴったり止まります。

<main class="stage">
<div class="wheel is-demo">
<div class="wheel__mask">
<div class="wheel__track" id="track" tabindex="0" role="listbox" aria-label="時刻(時)を選択">
<div class="wheel__pad" aria-hidden="true"></div>
</div>
<div class="wheel__hl" aria-hidden="true"></div>
</div>
<div class="wheel__label">時</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 · スクロールスナップ数字ホイールピッカー — 計測 累積面積 0.316% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
アラーム時刻の時(じ)選択や、予約人数を選ぶホイールに使えます。
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);
}
08アスペクト比クロッププリセットピッカー
画面で起きること
1:1・4:5・16:9・9:16のチップから一つを選ぶと、プレビューボックスのaspect-ratioがその比率へなめらかに変わります。

<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="アスペクト比を選択">
<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 · アスペクト比クロッププリセットピッカー — 計測 累積面積 12.704% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
プロフィール写真のクロップ比率選択や、リール・ショート動画のアップロード比率選びに向いています。
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; }
09左右移動デュアルリストピッカー
どんな動きか
左のリストで項目を選び矢印ボタンを押すと右のリストへ移り、矢印キーでリスト内を移動できます。

<main class="stage">
<div class="tf is-demo">
<div class="tf__col">
<div class="tf__h">利用可能</div>
<ul class="tf__list" id="avail" role="listbox" aria-label="利用可能な項目" aria-multiselectable="true"></ul>
</div>
<div class="tf__mid">
<button type="button" class="tf__btn" id="toRight" aria-label="選択済みへ移動">→</button>
<button type="button" class="tf__btn" id="toLeft" aria-label="利用可能へ移動">←</button>
</div>
<div class="tf__col">
<div class="tf__h">選択済み</div>
<ul class="tf__list" id="chosen" role="listbox" aria-label="選択済みの項目" 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 · 左右移動デュアルリストピッカー — 計測 累積面積 1.42% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
権限・役割の付与画面や、メール配信グループの構成に使えます。
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();
}
落とし穴 — 隠した装飾レイヤーはどこではみ出すか
01を最初に作ったとき、実際のクリック処理とは別にtranslateXだけで動く装飾用のスイープバーを::beforeで重ね、デモのループを独立して回そうとしました。ところがこのバーにtranslateX(366%)を与えると、見た目には舞台の外に出て見えないのにdocument.documentElement.scrollWidthはその分だけ伸びてしまい、480×300でも320px幅でもスクロールバーが生じる「はみ出し」判定を受けました。opacity: 0やpointer-events: noneで隠しても、レイアウト上の位置(と移動距離)はそのまま計測されるためです。結局この装飾レイヤーを消し、実際の.is-range・.is-edgeクラスが500msごとに切り替わる本物の状態変化だけをループに残しました。06の検索リストも似た理由で、閉じた状態でもリストの高さがそのまま残り、初回描画ではみ出しが検出されました — max-height: 0; overflow: hidden;で閉じた状態の箱を実際に畳まないと消えません。03が使う:has()はChrome・Edge 105+、Safari 15.4+、Firefox 121+でしか動かず、それより下のバージョンではカードが浮き上がらないだけで、ラジオの選択自体は正常に動きます。選ばれた人を画面に示すアバター部品はプロフィール UI デザイン 9種に別に集めてあります。9つのファイルがそのまま入ったzipの圧縮パスワードはuv8qjazcです。
アクセシビリティ
03・05・08は本物の<input type="radio">を見た目だけ隠しているので、スクリーンリーダーはラジオとしてそのまま読み上げ、矢印キーで選択肢を移動できます。01はrole="gridcell"を持つ日付マスのaria-selectedを選択のたびに直接書き換えるので、ネイティブのラジオではありませんが選択状態は同じように伝わります。02・04・09はラジオではないカスタムコントロールなので、それぞれrole="slider"・role="radiogroup"・role="listbox"とaria-valuenow・aria-checked・aria-selectedを状態が変わるたびに更新します。06は入力欄のフォーカスを移さずに、強調した項目へaria-selectedを付け替えて示し、07も同じくフォーカスを移さずにaria-activedescendantで中央に来た項目を読み上げます。05の数量はaria-live="polite"なので、ボタンを押すたびに新しい値が静かに読み上げられます。prefers-reduced-motion: reduceでは9つ全てのトランジションを止め、チェック・リング・バッジといった選択状態は開いた・選ばれた最終的な見た目のまま残るので、情報そのものは消えません。
@media (prefers-reduced-motion: reduce) {
.cal__day, .plan, .ava__item, .ar__box { transition: none; }
}
選択UI以外の単品コンポーネントはセグメントコントロールUI 9選にもあり、同じ選択UIでもセレクト・ドロップダウン系はセレクトボックス デザイン css 9選にまとめています。
FAQ
カレンダー 選択 html と、開始日・終了日を別々に入力する方式は何が違いますか?
日付欄を二つ別々に置くと値はそれぞれ独立して入りますが、01のようにグリッド一つで開始日と終了日を続けて選ぶと、その間の日付が即座に塗りつぶされ、何日間の期間なのかが一目で分かります。宿泊予約のように期間の長さ自体が重要な画面ほど、グリッド型が有利です。日付ではなく時刻と所要時間を選ぶ画面はタイムピッカー ui 9選が受け持ちます。
デュアルサムスライダーで二つのつまみが近づきすぎたらどうなりますか?
02のsetLo・setHiは常に最小値を最大値より5以上小さく保ちます。片方のつまみをもう一方の近くまで押すと、反対側のつまみも最小間隔ぶんだけ一緒に押され、二つのつまみが重なったり順序が入れ替わったりしません。
スクロールスナップのホイールピッカーは、マウスホイールがなくても使えますか?
はい。07のトラックにはscroll-snap-type: y mandatoryが付いているのでタッチスワイプでも自然に止まり、トラックにフォーカスして矢印キーを押すとscrollBy({ top, behavior: 'smooth' })で一段ずつ動きます。マウスホイールは同じスクロールを起こすもう一つの手段にすぎません。









