Tabs

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

개요 탭의 콘텐츠입니다. line variant.

Tabs

line / pill / segmented 3가지 변형의 탭 인터페이스. items 배열로 라벨과 패널을 한 번에 정의합니다.

Line variant

가장 일반적인 밑줄형. 콘텐츠 그룹의 구분에 적합합니다.

Source Code
const [value, setValue] = useState('overview');

<Tabs
  value={value}
  onChange={setValue}
  variant="line"
  items={[
    { value: 'overview', label: '개요', content: <div>...</div> },
    { value: 'features', label: '기능', content: <div>...</div> },
    { value: 'pricing', label: '가격', content: <div>...</div> },
  ]}
/>

개요 탭의 내용입니다.

Live Preview

Pill variant

필터/카테고리 칩 같은 가벼운 토글에 적합합니다.

Source Code
<Tabs variant="pill" items={[...]} />

전체 결과

Live Preview

Segmented variant

설정/뷰 토글처럼 상호 배타적 옵션에 적합합니다. fullWidth로 너비 분할.

Source Code
<Tabs variant="segmented" fullWidth items={[...]} />

일간 데이터

Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Tabstypescript
'use client';

/**
 * Tabs — 단일 active 값을 갖는 탭 인터페이스.
 *
 * - variant: line(밑줄) · pill(둥근 칩) · segmented(상자형 토글) 3종
 * - controlled props (value/onChange) — 외부 state로 제어
 * - items 배열로 라벨/패널을 한 곳에서 정의
 */

import { forwardRef, useId, type ReactNode } from 'react';
import { cn } from '@/lib/cn';

export type TabsVariant = 'line' | 'pill' | 'segmented';
export type TabsSize = 'sm' | 'md' | 'lg';

export interface TabItem<V extends string = string> {
  value: V;
  label: ReactNode;
  /** 패널 콘텐츠. 없으면 패널은 렌더링되지 않고 탭바만 노출. */
  content?: ReactNode;
  disabled?: boolean;
}

export interface TabsProps<V extends string = string> {
  items: TabItem<V>[];
  value: V;
  onChange: (value: V) => void;
  variant?: TabsVariant;
  size?: TabsSize;
  /** 탭바를 가로 폭 가득 채울지 여부. */
  fullWidth?: boolean;
  className?: string;
  /** 탭바(리스트) 추가 클래스. */
  listClassName?: string;
  /** 패널 추가 클래스. */
  panelClassName?: string;
  /** active 버튼 추가/오버라이드 클래스 (variant active 위에 병합 — accent 컬러 등). */
  activeClassName?: string;
  /** inactive 버튼 추가/오버라이드 클래스. */
  inactiveClassName?: string;
}

const sizeButton: Record<TabsSize, string> = {
  sm: 'px-3 py-1.5 text-xs',
  md: 'px-4 py-2 text-sm',
  lg: 'px-5 py-2.5 text-base',
};

const listVariant: Record<TabsVariant, string> = {
  line: 'border-b border-slate-200',
  pill: 'gap-1.5',
  segmented: 'gap-0.5 rounded-2xl border border-slate-200 bg-slate-50 p-1',
};

const buttonVariant: Record<TabsVariant, { base: string; active: string; inactive: string }> = {
  line: {
    base: '-mb-px border-b-2 font-semibold tracking-tight transition-colors duration-200',
    active: 'border-slate-900 text-slate-900',
    inactive: 'border-transparent text-slate-500 hover:text-slate-700',
  },
  pill: {
    base: 'rounded-full font-semibold tracking-tight transition-all duration-200',
    active: 'bg-slate-900 text-white shadow-sm shadow-slate-900/20',
    inactive: 'text-slate-600 hover:bg-slate-100',
  },
  segmented: {
    base: 'rounded-xl font-semibold tracking-tight transition-all duration-200',
    active: 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200/80',
    inactive: 'text-slate-500 hover:text-slate-700',
  },
};

function TabsInner<V extends string>(
  {
    items,
    value,
    onChange,
    variant = 'line',
    size = 'md',
    fullWidth = false,
    className,
    listClassName,
    panelClassName,
    activeClassName,
    inactiveClassName,
  }: TabsProps<V>,
  ref: React.Ref<HTMLDivElement>,
) {
  const baseId = useId();
  const active = items.find((it) => it.value === value);

  return (
    <div ref={ref} className={cn('w-full', className)}>
      <div
        role="tablist"
        className={cn(
          'flex items-center',
          fullWidth ? 'w-full' : 'inline-flex',
          listVariant[variant],
          listClassName,
        )}
      >
        {items.map((item) => {
          const isActive = item.value === value;
          const v = buttonVariant[variant];
          return (
            <button
              key={item.value}
              type="button"
              role="tab"
              id={`${baseId}-tab-${item.value}`}
              aria-controls={`${baseId}-panel-${item.value}`}
              aria-selected={isActive}
              tabIndex={isActive ? 0 : -1}
              disabled={item.disabled}
              onClick={() => !item.disabled && onChange(item.value)}
              className={cn(
                'inline-flex cursor-pointer items-center justify-center whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50',
                fullWidth && 'flex-1',
                sizeButton[size],
                v.base,
                isActive ? v.active : v.inactive,
                isActive ? activeClassName : inactiveClassName,
              )}
            >
              {item.label}
            </button>
          );
        })}
      </div>

      {active?.content !== undefined && (
        <div
          role="tabpanel"
          id={`${baseId}-panel-${active.value}`}
          aria-labelledby={`${baseId}-tab-${active.value}`}
          className={cn('mt-5 animate-in fade-in duration-200', panelClassName)}
        >
          {active.content}
        </div>
      )}
    </div>
  );
}

export const Tabs = forwardRef(TabsInner) as <V extends string = string>(
  props: TabsProps<V> & { ref?: React.Ref<HTMLDivElement> },
) => ReturnType<typeof TabsInner>;