09펼치지 않는 크기 세그먼트
선택지가 네 개뿐이라 목록을 열지 않고 알약 하나가 칸 사이를 미끄러지며, 품절 칸은 눌러도 넘어가지 않습니다.
쓰임선택지가 다섯 개 이하라 드롭다운을 여는 동작 자체가 낭비인 값(사이즈·기간·정렬)을 고르게 할 때
flex: 0 0 autotranslateXaria-disabled

<main class="stage">
<div class="sg is-demo">
<span class="sg__label" id="sg-label">사이즈</span>
<div class="sg__track" id="sg-track" role="listbox" aria-labelledby="sg-label" tabindex="0" aria-activedescendant="sg-o1">
<span class="sg__pill" aria-hidden="true"></span>
<span class="sg__cell" id="sg-o1" role="option" aria-selected="true">S</span>
<span class="sg__cell" id="sg-o2" role="option" aria-selected="false">M</span>
<span class="sg__cell" id="sg-o3" role="option" aria-selected="false">L</span>
<span class="sg__cell sg__cell--out" id="sg-o4" role="option" aria-selected="false" aria-disabled="true">XL</span>
</div>
<p class="sg__note">XL 품절</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.sg {
box-sizing: border-box;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sg__label {
display: block;
font-size: 10px; line-height: 12px; font-weight: 800;
letter-spacing: .04em;
color: rgba(255, 255, 255, .6);
margin: 0 0 $sp-2;
}
.sg__track {
position: relative;
box-sizing: border-box;
width: 232px;
display: flex;
padding: $sp-1;
border-radius: $r-pill;
background: rgba(255, 255, 255, .1);
}
.sg__track:focus-visible { outline: 2px solid $color; outline-offset: 4px; }
.sg__pill {
position: absolute;
top: $sp-1; left: $sp-1;
width: 56px; height: 34px;
border-radius: $r-pill;
background: $color;
box-shadow: $shadow-press;
transform: translateX(0);
transition: transform $duration $easing;
}
.sg__cell {
position: relative;
z-index: 1;
flex: 0 0 56px;
height: 34px;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 800;
color: rgba(255, 255, 255, .72);
cursor: pointer;
transition: color $duration $easing;
}
.sg__cell[aria-selected="true"] { color: #fff; }
.sg__cell--out {
color: rgba(255, 255, 255, .72);
cursor: not-allowed;
}
.sg__cell--out::after {
content: "";
position: absolute;
left: 14px; right: 14px; top: 50%;
height: 1px;
background: rgba(255, 255, 255, .32);
transform: rotate(-14deg);
}
.sg__note {
margin: $sp-2 0 0;
font-size: 10px; font-weight: 700;
color: rgba(255, 255, 255, .72);
}
// ── 자동 루프: 알약이 S→M→L 로 갔다가 같은 길로 되돌아온다 (품절 XL 은 건너뛴다) ──
.sg.is-demo .sg__pill { animation: sg-slide $dur-loop $easing infinite; }
@keyframes sg-slide {
0% { transform: translateX(0); }
25% { transform: translateX(56px); }
50% { transform: translateX(112px); }
75% { transform: translateX(56px); }
100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__pill { animation: none !important; }
.sg__pill, .sg__cell { 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);
}
.sg {
box-sizing: border-box;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sg__label {
display: block;
font-size: 10px;
line-height: 12px;
font-weight: 800;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 8px;
}
.sg__track {
position: relative;
box-sizing: border-box;
width: 232px;
display: flex;
padding: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
}
.sg__track:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
}
.sg__pill {
position: absolute;
top: 4px;
left: 4px;
width: 56px;
height: 34px;
border-radius: 999px;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.sg__cell {
position: relative;
z-index: 1;
flex: 0 0 56px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 800;
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.sg__cell[aria-selected=true] {
color: #fff;
}
.sg__cell--out {
color: rgba(255, 255, 255, 0.72);
cursor: not-allowed;
}
.sg__cell--out::after {
content: "";
position: absolute;
left: 14px;
right: 14px;
top: 50%;
height: 1px;
background: rgba(255, 255, 255, 0.32);
transform: rotate(-14deg);
}
.sg__note {
margin: 8px 0 0;
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.72);
}
.sg.is-demo .sg__pill {
animation: sg-slide 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes sg-slide {
0% {
transform: translateX(0);
}
25% {
transform: translateX(56px);
}
50% {
transform: translateX(112px);
}
75% {
transform: translateX(56px);
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__pill {
animation: none !important;
}
.sg__pill, .sg__cell {
transition: none;
}
}// 실제 동작: 알약이 칸 폭(56px)만큼 옮겨 간다. 품절 칸은 aria-disabled 라 옮겨가지 않는다.
var root = document.querySelector('.sg');
var track = document.getElementById('sg-track');
var pill = root.querySelector('.sg__pill');
var cells = [].slice.call(track.querySelectorAll('.sg__cell'));
var W = 56;
var at = 0;
function pick(i) {
if (i < 0 || i >= cells.length) return;
if (cells[i].getAttribute('aria-disabled') === 'true') return;
root.classList.remove('is-demo');
at = i;
pill.style.transform = 'translateX(' + (i * W) + 'px)';
cells.forEach(function (c, k) { c.setAttribute('aria-selected', String(k === i)); });
track.setAttribute('aria-activedescendant', cells[i].id);
}
cells.forEach(function (c, i) { c.addEventListener('click', function () { pick(i); }); });
track.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { pick(at + 1); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { pick(at - 1); e.preventDefault(); }
else if (e.key === 'Home') { pick(0); e.preventDefault(); }
else if (e.key === 'End') { pick(cells.length - 1); e.preventDefault(); }
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SizeSegment.module.scss";
export type SizeOption = { label: string; soldOut?: boolean };
export type SizeSegmentProps = {
label?: string;
note?: string;
sizes?: SizeOption[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
type ClickEvt = { stopPropagation: () => void };
const CELL = 56;
const DEFAULT_SIZES: SizeOption[] = [
{ label: "S" },
{ label: "M" },
{ label: "L" },
{ label: "XL", soldOut: true },
];
export default function SizeSegment({
label = "Size",
note = "XL sold out",
sizes = DEFAULT_SIZES,
duration = 180,
easing = "cubic-bezier(.34,1.56,.64,1)",
color = "#2f6df6",
}: SizeSegmentProps) {
const [picked, setPicked] = useState(0);
const trackRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function pick(i: number) {
if (i < 0 || i >= sizes.length) return;
if (sizes[i].soldOut) return;
setPicked(i);
}
return (
<div className={styles.sg} style={vars}>
<span className={styles.label} id="sg-label">{label}</span>
<div
ref={trackRef}
id="sg-track"
className={styles.track}
role="listbox"
aria-labelledby="sg-label"
tabIndex={0}
aria-activedescendant={`sg-o${picked}`}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowRight") { pick(picked + 1); e.preventDefault(); }
else if (e.key === "ArrowLeft") { pick(picked - 1); e.preventDefault(); }
else if (e.key === "Home") { pick(0); e.preventDefault(); }
else if (e.key === "End") { pick(sizes.length - 1); e.preventDefault(); }
}}
>
<span
className={styles.pill}
aria-hidden="true"
style={{ transform: `translateX(${picked * CELL}px)` }}
/>
{sizes.map((s, i) => (
<span
key={s.label}
id={`sg-o${i}`}
className={`${styles.cell} ${s.soldOut ? styles.isOut : ""}`}
role="option"
aria-selected={i === picked}
aria-disabled={s.soldOut ? true : undefined}
onClick={() => pick(i)}
>
{s.label}
</span>
))}
</div>
<p className={styles.note}>{note}</p>
</div>
);
}09 펼치지 않는 크기 세그먼트 — 측정 누적면적 3.94% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 셀렉트박스 디자인 9종 — 진짜 열리는 드롭다운
이 데모가 속한 글
css 셀렉트박스 디자인 9종 — 진짜 열리는 드롭다운css 셀렉트박스 디자인은 브라우저가 제 마음대로 그리는 기본 <select> 상자를 버리고, 버튼과 목록을 직접 만들어 여닫는 방식입니다. 아홉 개 중 하나는 목록을 아예 열지 않으며, 그 안의 품절 칸은 눌러도 이 글의 데모 전부 보기

