Tabs
Navigation
여러 콘텐츠 뷰 사이를 전환하는 탭.
Usage
같은 맥락의 콘텐츠를 여러 패널로 나눠 전환. 페이지 내 섹션 전환
import
import
import { Tabs } from '@mildang/design-system/Tabs';Tabs 하나만 가져오면 Tabs.List · Tabs.Trigger · Tabs.Content · Tabs.Icon · Tabs.Text · Tabs.ScrollSpy 를 그 아래에서 쓸 수 있다.
예제를 복사해 쓸 때 필요한 준비
• @mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.
• @mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.
Anatomy
tsx
import { Tabs } from '@mildang/design-system/Tabs';
export default function Example() {
return (
<Tabs.List>
<Tabs.Trigger>
<Tabs.Icon />
<Tabs.Text />
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content />
<Tabs.ScrollSpy />
);
}부품
필수 여부
반복
위치
Tabs.List
Tabs.Trigger
여러 개 가능
Tabs.List 안에 탭마다 둔다.
Tabs.Icon
Tabs.Trigger 안에 둔다.
Tabs.Text
Tabs.Trigger 안에 둔다.
Tabs.Content
여러 개 가능
Tabs.List의 Trigger value와 같은 value로 연결한다.
Tabs.ScrollSpy
Tabs와 연결된 스크롤 영역에 둔다.
- Tabs.List 안에 Trigger를 반복하고 각 Trigger의 value를 Content의 value와 일치시킨다.
- Trigger와 Content는 같은 Tabs 상태 context 안에 배치한다.
- 탭에 아이콘이나 보조 Chip을 넣을 때는 Trigger 안에서 Icon·Text와 함께 조합한다.
API Reference
Tabs Props
Prop
Type
Default
boolean
true
boolean
true
"scroll" | "stretch"
scroll
Tabs.List
탭 트리거들을 감싸고 배치 방식을 결정한다.
Prop
Type
Default
boolean
false
TabsScrollSpyOptions
지정 안 함
boolean
true
boolean
true
"scroll" | "stretch"
scroll
Tabs.Trigger
하나의 콘텐츠 패널을 선택하는 탭 버튼이다.
공개 Props 없음
Tabs.Icon
탭 레이블 앞이나 주변에 보조 아이콘을 표시한다.
Prop
Type
Default
ReactNode
지정 안 함
Tabs.Text
탭의 읽을 수 있는 레이블을 표시한다.
Prop
Type
Default
any
지정 안 함
boolean
지정 안 함
Tabs.Content
선택된 탭에 대응하는 콘텐츠 패널이다.
Prop
Type
Default
string
지정 안 함
Tabs.ScrollSpy
스크롤 위치에 따라 현재 탭을 동기화하는 보조 기능이다.
Prop
Type
Default
ReactNode
지정 안 함
boolean
false
number
0
(currentId: string) => void
지정 안 함
제어 및 조합
value와 onValueChange를 함께 사용하면 활성 탭을 외부 상태로 제어할 수 있습니다. 탭 레이블에는 아이콘과 Chip을 조합할 수 있습니다.
제어 탭
외부 상태로 활성 탭을 관리합니다.
import { useState } from 'react'; import { Tabs } from '@mildang/design-system/Tabs';
export default function TabsControlledExample() { const [value, setValue] = useState('account'); return <Tabs value={value} onValueChange={setValue}><Tabs.List><Tabs.Trigger value="account"><Tabs.Text>Account</Tabs.Text></Tabs.Trigger><Tabs.Trigger value="password"><Tabs.Text>Password</Tabs.Text></Tabs.Trigger></Tabs.List><Tabs.Content value="account">Account content</Tabs.Content><Tabs.Content value="password">Password content</Tabs.Content></Tabs>; }
아이콘과 Chip
탭에 아이콘과 보조 Chip을 조합합니다.
import { Tabs } from '@mildang/design-system/Tabs';
import { type ComponentProps } from 'react';
import StarFill from '@mildang/icons/react/star-fill';
import { Chip } from '@mildang/design-system/Chip';
import { css } from '@mildang/styled-system/css';
const containerStyle = css({
width: '400px',
});
const STORY_DEFAULT_ARGS = { ...({}), ...({}) } as ComponentProps<typeof Tabs>;
const TabsTabWithIconAndChipExampleRender = (args: ComponentProps<typeof Tabs>) => (
<Tabs {...args} defaultValue="tab1" className={containerStyle}>
<Tabs.List>
<Tabs.Trigger value="tab1">
<Tabs.Icon>
<StarFill />
</Tabs.Icon>
<Tabs.Text>TAB</Tabs.Text>
<Tabs.Text color="positive.text.base">20</Tabs.Text>
<Chip label="검수필요" size="sm" color="blue" type="fill" />
<Tabs.Icon>
<StarFill />
</Tabs.Icon>
</Tabs.Trigger>
<Tabs.Trigger value="tab2">
<Tabs.Icon>
<StarFill />
</Tabs.Icon>
<Tabs.Text>TAB</Tabs.Text>
<Tabs.Text color="positive.text.base">20</Tabs.Text>
<Chip label="검수필요" size="sm" color="red" type="fill" />
<Tabs.Icon color="critical.text.base">
<StarFill />
</Tabs.Icon>
</Tabs.Trigger>
<Tabs.Trigger value="tab3">
<Tabs.Icon>
<StarFill />
</Tabs.Icon>
<Tabs.Text>TAB</Tabs.Text>
<Tabs.Text color="positive.text.base">20</Tabs.Text>
<Chip label="검수필요" size="sm" color="red" type="fill" />
<Tabs.Icon color="critical.text.base">
<StarFill />
</Tabs.Icon>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="tab1">첫 번째 탭 내용입니다.</Tabs.Content>
<Tabs.Content value="tab2">두 번째 탭 내용입니다.</Tabs.Content>
<Tabs.Content value="tab3">세 번째 탭 내용입니다.</Tabs.Content>
</Tabs>
);
export default function TabsTabWithIconAndChipExample(props: Partial<ComponentProps<typeof Tabs>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tabs>;
return TabsTabWithIconAndChipExampleRender(mergedProps);
}
사용 가이드
권장
- scroll 타입은 항목 수나 레이블 폭이 유동적일 때 사용하고 오버플로우 안내를 유지한다.
- stretch 타입은 2~3개의 짧은 탭을 같은 너비로 나눌 때 사용한다.
지양
- 서로 다른 정보 구조나 페이지 이동을 한 Tabs 안에 섞지 않는다.
예제
기본 사용
scroll·stretch 두 type 을 나란히 비교한다. scroll 은 탭이 넘치면 가로 스크롤, stretch 는 폭을 균등하게 나눈다.
import type { ComponentProps } from 'react';
import { Tabs } from '@mildang/design-system/Tabs';
import { css } from '@mildang/styled-system/css';
const containerStyle = css({
width: '400px',
});
const STORY_DEFAULT_ARGS = { ...({}), ...({
showLine: true,
}) } as ComponentProps<typeof Tabs>;
const TabsDemoExampleRender = (args: ComponentProps<typeof Tabs>) => (
<Tabs {...args} defaultValue="account" className={containerStyle}>
<div>
<Tabs.List>
<Tabs.Trigger value="account">
<Tabs.Text>Account</Tabs.Text>
</Tabs.Trigger>
<Tabs.Trigger value="password">
<Tabs.Text>Password</Tabs.Text>
</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content value="account">Make changes to your account here.</Tabs.Content>
<Tabs.Content value="password">Change your password here.</Tabs.Content>
</Tabs>
);
export default function TabsDemoExample(props: Partial<ComponentProps<typeof Tabs>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tabs>;
return TabsDemoExampleRender(mergedProps);
}
ScrollWithGradientOverlay
import type { ComponentProps } from 'react';
import { Tabs } from '@mildang/design-system/Tabs';
import { css } from '@mildang/styled-system/css';
const containerStyle = css({
width: '400px',
});
const STORY_DEFAULT_ARGS = { ...({}), ...({
showLine: true,
type: 'scroll',
}) } as ComponentProps<typeof Tabs>;
const TabsScrollWithGradientOverlayExampleRender = (args: ComponentProps<typeof Tabs>) => (
<Tabs {...args} defaultValue="tab-0" className={containerStyle}>
<Tabs.List type="scroll">
{Array.from({ length: 10 }, (_, i) => (
<Tabs.Trigger key={i} value={`tab-${i}`}>
<Tabs.Text>Tab {i + 1}</Tabs.Text>
</Tabs.Trigger>
))}
</Tabs.List>
<Tabs.Content value="tab-0">
탭이 많아 스크롤될 때 오른쪽에 gradient overlay가 자동으로 나타납니다.
</Tabs.Content>
</Tabs>
);
export default function TabsScrollWithGradientOverlayExample(props: Partial<ComponentProps<typeof Tabs>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tabs>;
return TabsScrollWithGradientOverlayExampleRender(mergedProps);
}
StretchDemo
import type { ComponentProps } from 'react';
import { Tabs } from '@mildang/design-system/Tabs';
import { css } from '@mildang/styled-system/css';
const containerStyle = css({
width: '400px',
});
const STORY_DEFAULT_ARGS = { ...({}), ...({
showLine: true,
type: 'stretch',
}) } as ComponentProps<typeof Tabs>;
const TabsStretchDemoExampleRender = (args: ComponentProps<typeof Tabs>) => (
<Tabs {...args} defaultValue="account" className={containerStyle}>
<Tabs.List type="stretch">
<Tabs.Trigger value="account">
<Tabs.Text>Account</Tabs.Text>
</Tabs.Trigger>
<Tabs.Trigger value="password">
<Tabs.Text>Password</Tabs.Text>
</Tabs.Trigger>
<Tabs.Trigger value="settings">
<Tabs.Text>Settings</Tabs.Text>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">Account 탭 내용입니다.</Tabs.Content>
<Tabs.Content value="password">Password 탭 내용입니다.</Tabs.Content>
<Tabs.Content value="settings">Settings 탭 내용입니다.</Tabs.Content>
</Tabs>
);
export default function TabsStretchDemoExample(props: Partial<ComponentProps<typeof Tabs>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tabs>;
return TabsStretchDemoExampleRender(mergedProps);
}
StretchWithoutLine
import type { ComponentProps } from 'react';
import { Tabs } from '@mildang/design-system/Tabs';
import { css } from '@mildang/styled-system/css';
const containerStyle = css({
width: '400px',
});
const STORY_DEFAULT_ARGS = { ...({}), ...({
showLine: false,
type: 'stretch',
}) } as ComponentProps<typeof Tabs>;
const TabsStretchWithoutLineExampleRender = (args: ComponentProps<typeof Tabs>) => (
<Tabs {...args} defaultValue="account" className={containerStyle}>
<Tabs.List type="stretch">
<Tabs.Trigger value="account">
<Tabs.Text>Account</Tabs.Text>
</Tabs.Trigger>
<Tabs.Trigger value="password">
<Tabs.Text>Password</Tabs.Text>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">Account 탭 내용입니다.</Tabs.Content>
<Tabs.Content value="password">Password 탭 내용입니다.</Tabs.Content>
</Tabs>
);
export default function TabsStretchWithoutLineExample(props: Partial<ComponentProps<typeof Tabs>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tabs>;
return TabsStretchWithoutLineExampleRender(mergedProps);
}
WithoutLine
import type { ComponentProps } from 'react';
import { Tabs } from '@mildang/design-system/Tabs';
import { css } from '@mildang/styled-system/css';
const containerStyle = css({
width: '400px',
});
const STORY_DEFAULT_ARGS = { ...({}), ...({
showLine: false,
}) } as ComponentProps<typeof Tabs>;
const TabsWithoutLineExampleRender = (args: ComponentProps<typeof Tabs>) => (
<Tabs {...args} defaultValue="account" className={containerStyle}>
<Tabs.List>
<Tabs.Trigger value="account">
<Tabs.Text>Account</Tabs.Text>
</Tabs.Trigger>
<Tabs.Trigger value="password">
<Tabs.Text>Password</Tabs.Text>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">Make changes to your account here.</Tabs.Content>
<Tabs.Content value="password">Change your password here.</Tabs.Content>
</Tabs>
);
export default function TabsWithoutLineExample(props: Partial<ComponentProps<typeof Tabs>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tabs>;
return TabsWithoutLineExampleRender(mergedProps);
}
DisabledTab
import { Tabs } from '@mildang/design-system/Tabs';
import { type ComponentProps } from 'react';
import { css } from '@mildang/styled-system/css';
const containerStyle = css({
width: '400px',
});
const STORY_DEFAULT_ARGS = { ...({}), ...({}) } as ComponentProps<typeof Tabs>;
const TabsDisabledTabExampleRender = (args: ComponentProps<typeof Tabs>) => (
<Tabs {...args} defaultValue="account" className={containerStyle}>
<Tabs.List>
<Tabs.Trigger value="account">
<Tabs.Text>Account</Tabs.Text>
</Tabs.Trigger>
<Tabs.Trigger value="password" disabled>
<Tabs.Text>Password</Tabs.Text>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">Make changes to your account here.</Tabs.Content>
<Tabs.Content value="password">Change your password here.</Tabs.Content>
</Tabs>
);
export default function TabsDisabledTabExample(props: Partial<ComponentProps<typeof Tabs>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tabs>;
return TabsDisabledTabExampleRender(mergedProps);
}
TabWithTabIcon
import { Tabs } from '@mildang/design-system/Tabs';
import { type ComponentProps } from 'react';
import StarFill from '@mildang/icons/react/star-fill';
import { css } from '@mildang/styled-system/css';
const containerStyle = css({
width: '400px',
});
const STORY_DEFAULT_ARGS = { ...({}), ...({}) } as ComponentProps<typeof Tabs>;
const TabsTabWithTabIconExampleRender = (args: ComponentProps<typeof Tabs>) => (
<Tabs {...args} defaultValue="tab1" className={containerStyle}>
<Tabs.List>
<Tabs.Trigger value="tab1">
<Tabs.Icon>
<StarFill />
</Tabs.Icon>
<Tabs.Text>TAB</Tabs.Text>
<Tabs.Text color="positive.text.base">2012231</Tabs.Text>
<Tabs.Icon>
<StarFill />
</Tabs.Icon>
</Tabs.Trigger>
<Tabs.Trigger value="tab2">
<Tabs.Icon>
<StarFill />
</Tabs.Icon>
<Tabs.Text>TAB</Tabs.Text>
<Tabs.Text color="positive.text.base">20</Tabs.Text>
<Tabs.Icon color="critical.text.base">
<StarFill />
</Tabs.Icon>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="tab1">첫 번째 탭 내용입니다.</Tabs.Content>
<Tabs.Content value="tab2">두 번째 탭 내용입니다.</Tabs.Content>
</Tabs>
);
export default function TabsTabWithTabIconExample(props: Partial<ComponentProps<typeof Tabs>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tabs>;
return TabsTabWithTabIconExampleRender(mergedProps);
}
WithScrollSpy
import { Tabs } from '@mildang/design-system/Tabs';
import { type ComponentProps } from 'react';
import { useState } from 'react';
import { css } from '@mildang/styled-system/css';
const containerStyle = css({
width: '400px',
});
const scrollSpyContainerStyle = css({
width: '400px',
});
const scrollSpySectionStyle = css({
minHeight: '320px',
py: '24',
borderBottom: '1px solid',
borderColor: 'neutral.border.low',
});
const TabsWithScrollSpyDemo = ({ args }: { args: ComponentProps<typeof Tabs> }) => {
const [value, setValue] = useState('intro');
const handleValueChange = (nextValue: string) => {
setValue(nextValue);
const section = document.getElementById(nextValue);
section?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleChangeActiveId = (currentId: string) => {
setValue(currentId);
};
return (
<div className={scrollSpyContainerStyle}>
<Tabs {...args} value={value} onValueChange={handleValueChange} className={containerStyle}>
<Tabs.List
scrollSpy
scrollSpyOptions={{
onChangeActiveId: handleChangeActiveId,
offsetTop: 72,
}}
>
<Tabs.Trigger asChild value="intro">
<a href="#intro">
<Tabs.Text>Intro</Tabs.Text>
</a>
</Tabs.Trigger>
<Tabs.Trigger asChild value="lessons">
<a href="#lessons">
<Tabs.Text>Lessons</Tabs.Text>
</a>
</Tabs.Trigger>
<Tabs.Trigger asChild value="reviews">
<a href="#reviews">
<Tabs.Text>Reviews</Tabs.Text>
</a>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
<section id="intro" className={scrollSpySectionStyle}>
Intro section
</section>
<section id="lessons" className={scrollSpySectionStyle}>
Lessons section
</section>
<section id="reviews" className={scrollSpySectionStyle}>
Reviews section
</section>
</div>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
showLine: true,
}) } as ComponentProps<typeof Tabs>;
const TabsWithScrollSpyExampleRender = (args: ComponentProps<typeof Tabs>) => <TabsWithScrollSpyDemo args={args} />;
export default function TabsWithScrollSpyExample(props: Partial<ComponentProps<typeof Tabs>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tabs>;
return TabsWithScrollSpyExampleRender(mergedProps);
}
DynamicTabInsertion
import { Tabs } from '@mildang/design-system/Tabs';
import { type ComponentProps } from 'react';
import { useState } from 'react';
import { css } from '@mildang/styled-system/css';
const containerStyle = css({
width: '400px',
});
// 탭이 동적으로 추가/제거될 때 밑줄(thumb)이 active 탭을 계속 따라가야 한다.
// 초기 [메시지 작성, 액션] 중 '액션'이 active. 버튼으로 '추천 답변'을 액션 왼쪽(중간)에 삽입하면
// active 탭('액션')은 그대로지만 오른쪽으로 밀린다. 밑줄은 액션 아래에 유지되어야 한다.
const toggleButtonStyle = css({
alignSelf: 'flex-start',
px: '12',
py: '8',
borderRadius: '8',
border: '1px solid',
borderColor: 'neutral.border.low',
cursor: 'pointer',
});
const dynamicContainerStyle = css({
width: '400px',
display: 'flex',
flexDirection: 'column',
gap: '16',
});
const DynamicTabInsertionDemo = ({ args }: { args: ComponentProps<typeof Tabs> }) => {
const [hasRecommendation, setHasRecommendation] = useState(false);
const [value, setValue] = useState('action');
return (
<div className={dynamicContainerStyle}>
<button
type="button"
className={toggleButtonStyle}
onClick={() => setHasRecommendation((prev) => !prev)}
>
{hasRecommendation ? '추천 답변 탭 제거' : '추천 답변 탭 추가 (중간 삽입)'}
</button>
<Tabs {...args} value={value} onValueChange={setValue} className={containerStyle}>
<Tabs.List type="stretch">
<Tabs.Trigger value="compose">
<Tabs.Text>메시지 작성</Tabs.Text>
</Tabs.Trigger>
{hasRecommendation && (
<Tabs.Trigger value="recommendation">
<Tabs.Text>추천 답변</Tabs.Text>
</Tabs.Trigger>
)}
<Tabs.Trigger value="action">
<Tabs.Text>액션</Tabs.Text>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</div>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
showLine: true,
}) } as ComponentProps<typeof Tabs>;
const TabsDynamicTabInsertionExampleRender = (args: ComponentProps<typeof Tabs>) => <DynamicTabInsertionDemo args={args} />;
export default function TabsDynamicTabInsertionExample(props: Partial<ComponentProps<typeof Tabs>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tabs>;
return TabsDynamicTabInsertionExampleRender(mergedProps);
}
ScrollFadeToggle
import { Tabs } from '@mildang/design-system/Tabs';
import { type ComponentProps } from 'react';
import { useState } from 'react';
import { css } from '@mildang/styled-system/css';
const containerStyle = css({
width: '400px',
});
// 탭이 동적으로 추가/제거될 때 밑줄(thumb)이 active 탭을 계속 따라가야 한다.
// 초기 [메시지 작성, 액션] 중 '액션'이 active. 버튼으로 '추천 답변'을 액션 왼쪽(중간)에 삽입하면
// active 탭('액션')은 그대로지만 오른쪽으로 밀린다. 밑줄은 액션 아래에 유지되어야 한다.
const toggleButtonStyle = css({
alignSelf: 'flex-start',
px: '12',
py: '8',
borderRadius: '8',
border: '1px solid',
borderColor: 'neutral.border.low',
cursor: 'pointer',
});
const dynamicContainerStyle = css({
width: '400px',
display: 'flex',
flexDirection: 'column',
gap: '16',
});
// [회귀] scroll 타입에서 탭이 많아 오버플로우되면 gradient가 나타나고,
// 탭이 줄어 더 이상 넘치지 않으면 gradient가 사라져야 한다.
// thumb(밑줄)은 스크롤 컨테이너 내부의 absolute 요소라, 과거엔 이전 너비가 container.scrollWidth에
// 남아 탭을 줄여도 오버플로우로 오판돼 gradient가 계속 노출되던 버그가 있었다. 그 회귀 방지용.
const ScrollFadeToggleDemo = ({ args }: { args: ComponentProps<typeof Tabs> }) => {
const [many, setMany] = useState(true);
const count = many ? 10 : 2;
return (
<div className={dynamicContainerStyle}>
<button type="button" className={toggleButtonStyle} onClick={() => setMany((prev) => !prev)}>
{many ? '탭 줄이기 (2개 · fade 사라져야 함)' : '탭 늘리기 (10개 · fade 나타나야 함)'}
</button>
<Tabs {...args} defaultValue="tab-0" className={containerStyle}>
<Tabs.List type="scroll">
{Array.from({ length: count }, (_, i) => (
<Tabs.Trigger key={i} value={`tab-${i}`}>
<Tabs.Text>Tab {i + 1}</Tabs.Text>
</Tabs.Trigger>
))}
</Tabs.List>
</Tabs>
</div>
);
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
showLine: true,
type: 'scroll',
}) } as ComponentProps<typeof Tabs>;
const TabsScrollFadeToggleExampleRender = (args: ComponentProps<typeof Tabs>) => <ScrollFadeToggleDemo args={args} />;
export default function TabsScrollFadeToggleExample(props: Partial<ComponentProps<typeof Tabs>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tabs>;
return TabsScrollFadeToggleExampleRender(mergedProps);
}