# 세그먼트 컨트롤

한 번에 하나만 고르는 짧은 선택지를 나란히 보여 주는 컨트롤이에요.

- Type: component
- Theme: base
- Tags: arc, component, toggle, choice, tabs, selection
- English title: Segmented control
- URL: https://dsui.vercel.app/items/segmented-control

## Used by

- [문의 섹션](https://dsui.vercel.app/items/contact-section): 문의 양식, 연락 채널, 지점 안내 중에서 고를 수 있는 연락처 섹션이에요.
- [행동 유도 섹션](https://dsui.vercel.app/items/cta-section): 가운데 정렬, 좌우 분할, 배너 세 가지 모양으로 행동을 유도하는 섹션이에요.
- [데모 미리보기 틀](https://dsui.vercel.app/items/demo-frame): 기기 프리셋과 끌어서 조절하는 손잡이가 있는 미리보기 틀이에요.
- [문서 사이드바](https://dsui.vercel.app/items/docs-sidebar): 검색, 보기 범위, 접히는 분류가 있는 문서 왼쪽 메뉴예요.
- [자주 묻는 질문](https://dsui.vercel.app/items/faq-section): 아코디언, 분류 열, 검색 세 가지 방식으로 질문과 답을 보여 주는 섹션이에요.
- [히어로 섹션](https://dsui.vercel.app/items/hero-section): 가운데, 분할, 미니멀 세 가지 모양의 첫 화면 소개 섹션이에요.
- [뉴스레터 구독](https://dsui.vercel.app/items/newsletter-signup): 이메일을 받아 구독을 확인해 주고 지난 호를 겹쳐 보여 주는 가입 블록이에요.
- [요금제 비교](https://dsui.vercel.app/items/plan-comparison): 월간과 연간 결제를 바꿔 가며 두 요금제의 기능을 견줘 보는 블록이에요.
- [설정 패널](https://dsui.vercel.app/items/settings-panel): 소리, 화면 모드, 시즌 테마, 글자 크기, 움직임을 한 서랍에 모은 설정 블록이에요. 언어나 계정 같은 묶음도 더할 수 있어요.
- [사이트 푸터](https://dsui.vercel.app/items/site-footer): 열 링크, 미니멀, 로고 세 가지 모양의 사이트 하단 영역이에요.
- [사이트 헤더](https://dsui.vercel.app/items/site-header): 기본, 가운데 정렬, 메가 메뉴 세 가지 모양의 사이트 상단 내비게이션이에요.
- [통계 띠](https://dsui.vercel.app/items/stats-band): 핵심 숫자를 보이면 올라가듯 세어 주는 통계 영역이에요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| label | text | 라벨 | 보기 방식 |

## Source

### segmented-control.module.css

```css
/* The frame can shrink below its content inside flex and grid parents; the track then scrolls inside it. */
.root { display: inline-flex; min-width: 0; max-width: 100%; flex-shrink: 1; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface-muted); }
/* The track owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */
.track { --fade-start: 0px; --fade-end: 0px; isolation: isolate; display: flex; min-width: 0; gap: 2px; overflow-x: auto; overscroll-behavior-x: contain; padding: 3px; border-radius: inherit; scrollbar-width: none; -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); }
.track::-webkit-scrollbar { display: none; }
.track[data-fade-start] { --fade-start: 20px; }
.track[data-fade-end] { --fade-end: 20px; }
/* Items never scale or change weight; only the highlight travels and the label color follows it. */
.button { position: relative; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - 4px); padding: 0 12px; background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .button:hover { color: var(--foreground); } }
.button:active, .button[aria-pressed="true"] { color: var(--foreground); }
.selection { position: absolute; z-index: -1; inset: 0; border: 1px solid var(--border); border-radius: inherit; background: var(--surface); box-shadow: var(--shadow-resting); }
.label { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 6px; }
@media (max-width: 380px) { .button { padding: 0 10px; } }
@media (prefers-reduced-motion: reduce) { .button { transition: none; } }

```

### segmented-control.tsx

```tsx
"use client";

import { useEffect, useId, useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
import { LayoutGroup, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./segmented-control.module.css";
import { playUiSound } from "../sound/play";

export interface Segment { value: string; label: string; /** Optional content after the label, such as a badge. */ accessory?: ReactNode; }
export interface SegmentedControlProps {
  options: Segment[];
  value: string;
  onValueChange: (value: string) => void;
  label?: string;
  /** Called when the pointer or focus reaches an option, before it is chosen. Use it to start loading what that option shows. */
  onOptionIntent?: (value: string) => void;
  className?: string;
}

export default function SegmentedControl({ options, value, onValueChange, label, onOptionIntent, className }: SegmentedControlProps) {
  const id = useId();
  const reduced = useReducedMotion();
  const track = useRef<HTMLDivElement>(null);

  // When the options are wider than the container, the track scrolls inside itself. Edges fade only on the side with more to see.
  useLayoutEffect(() => {
    const node = track.current;
    if (!node) return;
    const edges = () => {
      const rest = node.scrollWidth - node.clientWidth - node.scrollLeft;
      node.toggleAttribute("data-fade-start", node.scrollLeft > 1);
      node.toggleAttribute("data-fade-end", rest > 1);
    };
    edges();
    node.addEventListener("scroll", edges, { passive: true });
    const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(edges);
    observer?.observe(node);
    return () => { node.removeEventListener("scroll", edges); observer?.disconnect(); };
  }, [options.length]);

  // The selected option is always scrolled fully into view, with a little room so it clears the fade.
  const first = useRef(true);
  useEffect(() => {
    const node = track.current;
    const button = node?.querySelector<HTMLElement>('[aria-pressed="true"]');
    if (!node || !button || node.scrollWidth <= node.clientWidth) { first.current = false; return; }
    const room = 20, start = button.offsetLeft - room, end = button.offsetLeft + button.offsetWidth + room - node.clientWidth;
    const left = node.scrollLeft > start ? start : node.scrollLeft < end ? end : node.scrollLeft;
    if (left !== node.scrollLeft) node.scrollTo({ left: Math.max(0, left), behavior: first.current || reduced ? "auto" : "smooth" });
    first.current = false;
  }, [value, reduced]);

  // Arrow keys, Home and End move the selection like a tab list; only the selected option is a tab stop.
  const selectedIndex = Math.max(0, options.findIndex(option => option.value === value));
  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    const last = options.length - 1;
    const target = event.key === "ArrowRight" || event.key === "ArrowDown" ? (selectedIndex === last ? 0 : selectedIndex + 1)
      : event.key === "ArrowLeft" || event.key === "ArrowUp" ? (selectedIndex === 0 ? last : selectedIndex - 1)
        : event.key === "Home" ? 0 : event.key === "End" ? last : -1;
    if (target < 0 || !options[target]) return;
    event.preventDefault();
    playUiSound("tick");
    onValueChange(options[target].value);
    track.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(options[target].value)}"]`)?.focus({ preventScroll: true });
  };

  return <div className={`${styles.root} ${className ?? ""}`} role="group" aria-label={label}>
    <LayoutGroup id={id}><motion.div ref={track} layoutScroll className={styles.track}>
      {options.map((option, index) => <button key={option.value} id={`${id}-${option.value}`} className={styles.button} type="button" data-value={option.value} aria-pressed={value === option.value} tabIndex={index === selectedIndex ? 0 : -1} onClick={() => { if (value !== option.value) playUiSound("tick"); onValueChange(option.value); }} onKeyDown={onKeyDown} onPointerEnter={onOptionIntent ? () => onOptionIntent(option.value) : undefined} onFocus={onOptionIntent ? () => onOptionIntent(option.value) : undefined}>
        {value === option.value && <motion.span className={styles.selection} layoutId="selection" layoutDependency={value} transition={reduced ? { duration: 0 } : motionTokens.spring.morph} aria-hidden="true" />}
        <span className={styles.label}>{option.label}{option.accessory}</span>
      </button>)}
    </motion.div></LayoutGroup>
  </div>;
}

```
