Tabs
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
개요 탭의 콘텐츠입니다. line variant.
Tabs
line / pill / segmented 3가지 변형의 탭 인터페이스. items 배열로 라벨과 패널을 한 번에 정의합니다.
Line variant
가장 일반적인 밑줄형. 콘텐츠 그룹의 구분에 적합합니다.
Source Code
개요 탭의 내용입니다.
Live Preview
Pill variant
필터/카테고리 칩 같은 가벼운 토글에 적합합니다.
Source Code
전체 결과
Live Preview
Segmented variant
설정/뷰 토글처럼 상호 배타적 옵션에 적합합니다. fullWidth로 너비 분할.
Source Code
일간 데이터
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>;