05Selector pill sliding between two slots
A dark pill slides between two labels inside a long track and only the label it lands on flips its color, and because the slots are an even grid the pill never stops off a label.
Best forPicking one of two ranges such as day or week
grid-template-columnstranslateXsteps(1, end)

<main class="stage">
<div class="sg is-demo" id="sg">
<div class="sg__track">
<span class="sg__knob" aria-hidden="true"></span>
<button class="sg__cell" id="sgDay" type="button" aria-pressed="true">Daily</button>
<button class="sg__cell" id="sgWeek" type="button" aria-pressed="false">Weekly</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 두 칸 선택 알약 — 파란 면($subject-blue-deep) 트랙 안을 짙은 잉크 알약 노브가 좌우로 미끄러진다.
// 칸은 grid repeat(2, 1fr) 로 균등해야 translateX(칸 폭 고정 px) 가 라벨을 빗나가지 않는다(BD19 함정).
// 노브 이동은 $ease-pop 의 오버슈트 한 번, 글자 반전은 steps(1, end) 컷 — 부드러움과 딱 끊김이 역할을 나눈다.
// 금속감은 무대를 단색으로 두고 재질로만: 트랙 위 얇은 하이라이트(inset), 노브 양 끝의 세로 그립 줄.
// 비선택 글자 rgba($subject-cream, .85) — 파란 면 위 약 4.7:1 로 AA 를 지킨다(알파를 더 내리면 4.5 아래로 떨어진다).
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-ink);
.sg { display: grid; place-items: center; width: 100%; }
// ── 트랙(파란 면) ────────────────────────────────────────────
.sg__track {
--travel: 116px; // 칸 폭 = 트랙 240 − 좌우 패딩 8 을 2 로 나눈 값. 좁은 화면에서는 96px
position: relative;
display: grid; grid-template-columns: repeat(2, 1fr);
width: 240px; height: 48px; padding: $sp-1;
border-radius: $r-pill;
background: linear-gradient(180deg, rgba($subject-cream, .1), rgba($subject-cream, .02) 30%, rgba($subject-ink, .35)), $color;
box-shadow: inset 0 1px 0 rgba($subject-cream, .28), inset 0 -1px 0 rgba($subject-ink, .45), 0 10px 22px rgba(0, 0, 0, .4);
}
// 짙은 잉크 알약 노브 — 위가 밝고 아래가 어두운 금속 면, 양 끝에 세로 그립 줄 두 쌍
.sg__knob {
position: absolute; left: 8px; top: 4px;
width: 108px; height: 40px; border-radius: $r-pill;
background: linear-gradient(180deg, rgba($subject-cream, .16), rgba($subject-cream, .02) 34%, rgba(0, 0, 0, .38)), $subject-ink;
box-shadow: inset 0 1px 0 rgba($subject-cream, .3), 0 4px 10px rgba(0, 0, 0, .5);
transform: translateX(0);
transition: transform $duration $easing;
}
.sg__knob::before, .sg__knob::after {
content: ""; position: absolute; top: 12px; width: 6px; height: 16px;
background-image: linear-gradient(90deg, rgba($subject-cream, .22) 0 2px, transparent 2px 4px, rgba($subject-cream, .22) 4px 6px);
}
.sg__knob::before { left: 12px; }
.sg__knob::after { right: 12px; }
// ── 두 칸(라벨 버튼) — 노브 위에 올라앉는다(position 으로 노브보다 위에 그린다) ──
.sg__cell {
position: relative; z-index: 1;
margin: 0; padding: 0; border: 0; border-radius: $r-pill;
background: none; cursor: pointer;
font-weight: 700; font-size: 15px; line-height: 1;
color: rgba($subject-cream, .85);
}
.sg__cell[aria-pressed="true"] { color: $subject-cream; }
.sg__cell:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
// 주간 선택 — 알약이 오른쪽 칸 폭만큼 미끄러진다
.sg.is-week .sg__knob { transform: translateX(var(--travel)); }
// ── 미리보기 루프(2초 한 바퀴) ────────────────────────────────
// 알약은 왕복(오버슈트 포함), 글자 반전은 노브가 자리를 잡은 뒤(30%) 한 프레임 컷.
.sg.is-demo .sg__knob {
animation-name: sgSweep;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sgSweep {
0%, 8% { transform: translateX(0); }
24% { transform: translateX(calc(var(--travel) + 4px)); }
30%, 50% { transform: translateX(var(--travel)); }
66% { transform: translateX(-4px); }
72%, 100% { transform: translateX(0); }
}
.sg.is-demo .sg__cell {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sg.is-demo .sg__cell:first-child { animation-name: sgDayCut; }
.sg.is-demo .sg__cell:last-child { animation-name: sgWeekCut; }
@keyframes sgDayCut {
0%, 30% { color: $subject-cream; }
34%, 72% { color: rgba($subject-cream, .85); }
76%, 100% { color: $subject-cream; }
}
@keyframes sgWeekCut {
0%, 30% { color: rgba($subject-cream, .85); }
34%, 72% { color: $subject-cream; }
76%, 100% { color: rgba($subject-cream, .85); }
}
@media (max-width: 340px) {
.sg__track { --travel: 96px; width: 200px; height: 44px; }
.sg__knob { width: 88px; height: 36px; }
.sg__knob::before, .sg__knob::after { top: 10px; height: 14px; }
.sg__cell { font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.sg .sg__knob { transition: none !important; }
.sg.is-demo .sg__knob { animation: none !important; }
.sg.is-demo .sg__cell { animation: none !important; }
}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 {
display: grid;
place-items: center;
width: 100%;
}
.sg__track {
--travel: 116px;
position: relative;
display: grid;
grid-template-columns: repeat(2, 1fr);
width: 240px;
height: 48px;
padding: 4px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(255, 247, 230, 0.1), rgba(255, 247, 230, 0.02) 30%, rgba(23, 20, 26, 0.35)), #0a4fe8;
box-shadow: inset 0 1px 0 rgba(255, 247, 230, 0.28), inset 0 -1px 0 rgba(23, 20, 26, 0.45), 0 10px 22px rgba(0, 0, 0, 0.4);
}
.sg__knob {
position: absolute;
left: 8px;
top: 4px;
width: 108px;
height: 40px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(255, 247, 230, 0.16), rgba(255, 247, 230, 0.02) 34%, rgba(0, 0, 0, 0.38)), #17141a;
box-shadow: inset 0 1px 0 rgba(255, 247, 230, 0.3), 0 4px 10px rgba(0, 0, 0, 0.5);
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.sg__knob::before, .sg__knob::after {
content: "";
position: absolute;
top: 12px;
width: 6px;
height: 16px;
background-image: linear-gradient(90deg, rgba(255, 247, 230, 0.22) 0 2px, transparent 2px 4px, rgba(255, 247, 230, 0.22) 4px 6px);
}
.sg__knob::before {
left: 12px;
}
.sg__knob::after {
right: 12px;
}
.sg__cell {
position: relative;
z-index: 1;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
cursor: pointer;
font-weight: 700;
font-size: 15px;
line-height: 1;
color: rgba(255, 247, 230, 0.85);
}
.sg__cell[aria-pressed=true] {
color: #fff7e6;
}
.sg__cell:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.sg.is-week .sg__knob {
transform: translateX(var(--travel));
}
.sg.is-demo .sg__knob {
animation-name: sgSweep;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes sgSweep {
0%, 8% {
transform: translateX(0);
}
24% {
transform: translateX(calc(var(--travel) + 4px));
}
30%, 50% {
transform: translateX(var(--travel));
}
66% {
transform: translateX(-4px);
}
72%, 100% {
transform: translateX(0);
}
}
.sg.is-demo .sg__cell {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sg.is-demo .sg__cell:first-child {
animation-name: sgDayCut;
}
.sg.is-demo .sg__cell:last-child {
animation-name: sgWeekCut;
}
@keyframes sgDayCut {
0%, 30% {
color: #fff7e6;
}
34%, 72% {
color: rgba(255, 247, 230, 0.85);
}
76%, 100% {
color: #fff7e6;
}
}
@keyframes sgWeekCut {
0%, 30% {
color: rgba(255, 247, 230, 0.85);
}
34%, 72% {
color: #fff7e6;
}
76%, 100% {
color: rgba(255, 247, 230, 0.85);
}
}
@media (max-width: 340px) {
.sg__track {
--travel: 96px;
width: 200px;
height: 44px;
}
.sg__knob {
width: 88px;
height: 36px;
}
.sg__knob::before, .sg__knob::after {
top: 10px;
height: 14px;
}
.sg__cell {
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.sg .sg__knob {
transition: none !important;
}
.sg.is-demo .sg__knob {
animation: none !important;
}
.sg.is-demo .sg__cell {
animation: none !important;
}
}var sgRoot = document.getElementById('sg');
var sgCells = sgRoot.querySelectorAll('.sg__cell');
// 두 칸 중 하나 고르기 — 이미 눌린 칸이면 그대로 두고, 아니면 알약을 옮긴다.
// 칸은 button 이라 탭과 엔터로도 전환된다(aria-pressed 로 선택 상태를 알린다).
function sgSelect(cell) {
for (var i = 0; i < sgCells.length; i++) {
sgCells[i].setAttribute('aria-pressed', sgCells[i] === cell ? 'true' : 'false');
}
sgRoot.classList.toggle('is-week', cell.id === 'sgWeek');
}
sgRoot.addEventListener('pointerdown', function () { sgRoot.classList.remove('is-demo'); });
for (var j = 0; j < sgCells.length; j++) {
sgCells[j].addEventListener('click', function () { sgSelect(this); });
}// MetalSegmentSwitch — vanilla 판(run/076/demo/05-real-metal-segment-switch/index.html) 의 React 포트.
// 파란 면 트랙 안을 짙은 잉크 알약 노브가 좌우로 미끄러진다. 이미 눌린 칸이면 그대로 둔다.
// 값·키프레임은 vanilla 와 같다(다시 타이핑하지 않는다). 손이 닿으면 미리보기 루프를 뗀다.
import { useState } from "react";
import styles from "./MetalSegmentSwitch.module.scss";
export type MetalSegmentSwitchProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = { [k: string]: any };
const LABEL_DAY = "일간";
const LABEL_WEEK = "주간";
export default function MetalSegmentSwitch({
duration = 300,
easing = "cubic-bezier(.2, 1.4, .4, 1)", // = $ease-pop
color = "#0a4fe8",
}: MetalSegmentSwitchProps) {
const [demo, setDemo] = useState(true);
const [week, setWeek] = useState(false);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
const cls = [styles.root, demo ? styles.isDemo : "", week ? styles.isWeek : ""].filter(Boolean).join(" ");
function wake() {
setDemo(false);
}
function pick(next: boolean) {
setDemo(false);
setWeek(next);
}
return (
<main className={styles.stage}>
<div className={cls} style={vars} onPointerDown={wake}>
<div className={styles.track}>
<span className={styles.knob} aria-hidden="true"></span>
<button
className={styles.cell}
type="button"
aria-pressed={!week}
onClick={() => pick(false)}
>
{LABEL_DAY}
</button>
<button
className={styles.cell}
type="button"
aria-pressed={week}
onClick={() => pick(true)}
>
{LABEL_WEEK}
</button>
</div>
</div>
</main>
);
}05 Selector pill sliding between two slots — Measured area 8.249% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · CSS Toggle Switch: 9 Realistic Switches to Paste
The post this demo lives in
CSS Toggle Switch: 9 Realistic Switches to PasteA css toggle switch is the small on-off control visitors flip without thinking, and here are nine built with no library, See all demos in this post

