02키보드 하이라이트 레일
위아래 화살표를 누르면 색칠된 막대 하나가 항목 사이를 미끄러져 옮겨 다니고, Home·End 로 양 끝까지 한 번에 갑니다.
쓰임마우스 없이 키보드만으로 고르는 사람이 지금 어디에 있는지 한눈에 보이게 하고 싶을 때
translateYaria-activedescendantArrowDown

<main class="stage">
<div class="kb is-demo">
<span class="kb__label" id="kb-label">정렬 기준</span>
<div class="kb__panel">
<div class="kb__rail" aria-hidden="true"></div>
<ul class="kb__list" id="kb-list" role="listbox" aria-labelledby="kb-label" tabindex="0" aria-activedescendant="kb-o1">
<li class="kb__opt" id="kb-o1" role="option" aria-selected="true">최신순</li>
<li class="kb__opt" id="kb-o2" role="option" aria-selected="false">인기순</li>
<li class="kb__opt" id="kb-o3" role="option" aria-selected="false">가격 낮은순</li>
<li class="kb__opt" id="kb-o4" role="option" aria-selected="false">가격 높은순</li>
</ul>
</div>
<span class="kb__hint">↑ ↓ Home End</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.kb {
box-sizing: border-box;
width: min(216px, 84vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.kb__label {
display: block;
font-size: 10px; line-height: 12px; font-weight: 800;
letter-spacing: .04em;
color: rgba(23, 20, 26, .72);
margin: 0 0 $sp-1;
}
.kb__panel {
position: relative;
box-sizing: border-box;
padding: $sp-1;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-press;
}
.kb__rail {
position: absolute;
top: $sp-1; left: $sp-1; right: $sp-1;
height: 28px;
border-radius: $r-sm;
background: rgba(47, 109, 246, .14);
box-shadow: inset 0 0 0 1px rgba(47, 109, 246, .35);
transform: translateY(0);
transition: transform $duration $easing;
}
.kb__list { margin: 0; padding: 0; list-style: none; position: relative; }
.kb__list:focus-visible { outline: 2px solid $color; outline-offset: 4px; border-radius: $r-sm; }
.kb__opt {
box-sizing: border-box;
height: 28px;
display: flex; align-items: center;
padding: 0 $sp-2;
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .7);
cursor: pointer;
}
.kb__opt.is-at { color: $color; }
.kb__opt.is-sel { font-weight: 800; }
.kb__hint {
display: block;
margin: $sp-2 0 0;
font-size: 10px; font-weight: 700;
letter-spacing: .08em;
color: rgba(23, 20, 26, .72);
}
// ── 자동 루프: 막대가 1→4 로 내려갔다 다시 1 로 올라온다. 되돌아오므로 이음새가 없다 ──
.kb.is-demo .kb__rail { animation: kb-rail $dur-loop $ease-spring infinite; }
@keyframes kb-rail {
0% { transform: translateY(0); }
17% { transform: translateY(28px); }
34% { transform: translateY(56px); }
50% { transform: translateY(84px); }
67% { transform: translateY(56px); }
84% { transform: translateY(28px); }
100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.kb.is-demo .kb__rail { animation: none !important; }
.kb__rail { 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);
}
.kb {
box-sizing: border-box;
width: min(216px, 84vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.kb__label {
display: block;
font-size: 10px;
line-height: 12px;
font-weight: 800;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
margin: 0 0 4px;
}
.kb__panel {
position: relative;
box-sizing: border-box;
padding: 4px;
background: #fff;
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.kb__rail {
position: absolute;
top: 4px;
left: 4px;
right: 4px;
height: 28px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.14);
box-shadow: inset 0 0 0 1px rgba(47, 109, 246, 0.35);
transform: translateY(0);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.kb__list {
margin: 0;
padding: 0;
list-style: none;
position: relative;
}
.kb__list:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
border-radius: 8px;
}
.kb__opt {
box-sizing: border-box;
height: 28px;
display: flex;
align-items: center;
padding: 0 8px;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.7);
cursor: pointer;
}
.kb__opt.is-at {
color: #2f6df6;
}
.kb__opt.is-sel {
font-weight: 800;
}
.kb__hint {
display: block;
margin: 8px 0 0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
color: rgba(23, 20, 26, 0.72);
}
.kb.is-demo .kb__rail {
animation: kb-rail 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes kb-rail {
0% {
transform: translateY(0);
}
17% {
transform: translateY(28px);
}
34% {
transform: translateY(56px);
}
50% {
transform: translateY(84px);
}
67% {
transform: translateY(56px);
}
84% {
transform: translateY(28px);
}
100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.kb.is-demo .kb__rail {
animation: none !important;
}
.kb__rail {
transition: none;
}
}// 실제 동작: 화살표·Home·End 로 하이라이트 막대가 옮겨 다니고, Enter 로 그 줄이 선택된다.
var root = document.querySelector('.kb');
var list = document.getElementById('kb-list');
var rail = document.querySelector('.kb__rail');
var opts = [].slice.call(list.querySelectorAll('.kb__opt'));
var ROW = 28;
var at = 0;
function move(i) {
root.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
rail.style.transform = 'translateY(' + (at * ROW) + 'px)';
list.setAttribute('aria-activedescendant', opts[at].id);
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
}
function choose() {
opts.forEach(function (o, k) {
o.setAttribute('aria-selected', String(k === at));
o.classList.toggle('is-sel', k === at);
});
}
list.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { move(at + 1); e.preventDefault(); }
else if (e.key === 'ArrowUp') { move(at - 1); e.preventDefault(); }
else if (e.key === 'Home') { move(0); e.preventDefault(); }
else if (e.key === 'End') { move(opts.length - 1); e.preventDefault(); }
else if (e.key === 'Enter' || e.key === ' ') { choose(); e.preventDefault(); }
});
opts.forEach(function (o, i) {
o.addEventListener('click', function () { move(i); choose(); });
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HighlightRail.module.scss";
export type HighlightRailProps = {
label?: string;
options?: string[];
hint?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
type ClickEvt = { stopPropagation: () => void };
const ROW = 28;
export default function HighlightRail({
label = "Sort by",
options = ["Newest", "Most popular", "Price: low to high", "Price: high to low"],
hint = "↑ ↓ Home End",
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: HighlightRailProps) {
const [at, setAt] = useState(0);
const [picked, setPicked] = useState(0);
const listRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function move(i: number) {
setAt(Math.max(0, Math.min(i, options.length - 1)));
}
return (
<div className={styles.kb} style={vars}>
<span className={styles.label} id="kb-label">{label}</span>
<div className={styles.panel}>
<div
className={styles.rail}
aria-hidden="true"
style={{ transform: `translateY(${at * ROW}px)` }}
/>
<ul
ref={listRef}
id="kb-list"
className={styles.list}
role="listbox"
aria-labelledby="kb-label"
aria-activedescendant={`kb-o${at}`}
tabIndex={0}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown") { move(at + 1); e.preventDefault(); }
else if (e.key === "ArrowUp") { move(at - 1); e.preventDefault(); }
else if (e.key === "Home") { move(0); e.preventDefault(); }
else if (e.key === "End") { move(options.length - 1); e.preventDefault(); }
else if (e.key === "Enter" || e.key === " ") { setPicked(at); e.preventDefault(); }
}}
>
{options.map((o, i) => (
<li
key={o}
id={`kb-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""} ${i === picked ? styles.isSel : ""}`}
role="option"
aria-selected={i === picked}
onClick={() => {
move(i);
setPicked(i);
listRef.current?.focus({ preventScroll: true });
}}
>
{o}
</li>
))}
</ul>
</div>
<span className={styles.hint}>{hint}</span>
</div>
);
}02 키보드 하이라이트 레일 — 측정 누적면적 15.938% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 셀렉트박스 디자인 9종 — 진짜 열리는 드롭다운
이 데모가 속한 글
css 셀렉트박스 디자인 9종 — 진짜 열리는 드롭다운css 셀렉트박스 디자인은 브라우저가 제 마음대로 그리는 기본 <select> 상자를 버리고, 버튼과 목록을 직접 만들어 여닫는 방식입니다. 아홉 개 중 하나는 목록을 아예 열지 않으며, 그 안의 품절 칸은 눌러도 이 글의 데모 전부 보기


