Indicator
Feedback & Status
캐러셀·배너의 현재 위치나 진행 단계를 점으로 보여주는 표식.
Usage
캐러셀·배너에서 현재 위치를 점으로 보여줄 때 3~8단계의 짧은 진행 상황을 가볍게 표시할 때 세밀한 진행률 표시는 Progress, 단계별 안내는 Stepper를 사용
import
import
import { Indicator } from '@mildang/design-system/Indicator';예제를 복사해 쓸 때 필요한 준비
• @mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.
• @mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.
API Reference
Indicator Props
Prop
Type
Default
number
지정 안 함
number
지정 안 함
string
지정 안 함
string
지정 안 함
(index: number) => string
지정 안 함
(index: number) => void
지정 안 함
"sm" | "md"
md
연결 패턴
Indicator는 자기 상태를 관리하지 않는 presentational component다. 부모가 activeIndex를
들고 있다가 onSelect로 갱신하는 controlled 패턴이 기본이다. onSelect를 넘기지 않으면
클릭 불가한 정적 표시(role="group", 점은 aria-hidden)로 렌더된다 — 뒤로 못 가는
선형 스텝 표시(온보딩 등)에 적합하다.
이미지·콘텐츠 슬라이더 등 다른 컴포넌트와 연결할 때도 마찬가지로, 슬라이더 쪽 상태를
그대로 activeIndex/onSelect에 전달하면 두 컴포넌트가 같은 state를 공유해 동기화된다.
Carousel(embla)과도 API를 그대로 꽂아 연결할 수 있다. 다만 Carousel.Dots는 embla
context 기반의 편의 컴포넌트로 이미 존재한다 — 캐러셀 내부 위치 표시가 목적이면 기본은
Carousel.Dots를 쓰고, 캐러셀 밖에서 재사용하거나 스타일을 커스터마이즈해야 할 때만
Indicator를 직접 연결한다.
기본 사용
부모가 activeIndex를 들고 onSelect로 갱신하는 controlled 패턴의 출발점입니다.
import type { ComponentProps } from 'react';
import { Indicator } from '@mildang/design-system/Indicator';
import { useState } from 'react';
const STORY_DEFAULT_ARGS = { ...({
total: 5,
activeIndex: 0,
size: 'md',
}), ...({}) } as ComponentProps<typeof Indicator>;
const IndicatorDemoExampleRender = (args: ComponentProps<typeof Indicator>) => {
const [current, setCurrent] = useState(args.activeIndex ?? 0);
return <Indicator {...args} activeIndex={current} onSelect={setCurrent} />;
};
export default function IndicatorDemoExample(props: Partial<ComponentProps<typeof Indicator>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Indicator>;
return IndicatorDemoExampleRender(mergedProps);
}
사이즈와 상태
보이는 점 자체는 sm/md 모두 6px로 같다. 달라지는 건 점을 감싸는 클릭 히트 영역(10px/14px) 뿐이지만, 점이 히트 영역 가운데 놓이므로 결과적으로 점 사이 시각적 간격이 달라진다.
| size | 점 크기 | 점 사이 간격 | 쓰는 자리 |
|---|---|---|---|
sm | 6px | 4px | 카드 안 미니 캐러셀, 리스트 아이템 내부, 툴바/헤더처럼 공간이 좁은 UI |
md(기본값) | 6px | 8px | 대부분의 모바일/데스크톱 캐러셀, 온보딩, 모달 내부 |
점은 Active(현재 위치, 채움/불투명도로 강조)와 Default(나머지, 낮은 불투명도) 두 상태만
가진다 — activeIndex와 일치하는 점에만 강조 스타일이 붙는다.
Sizes
sm과 md를 나란히 비교합니다. 보이는 점은 같고 점 사이 간격만 다릅니다.
import { useState } from 'react';
import { VStack, HStack } from '@mildang/styled-system/jsx';
import { css } from '@mildang/styled-system/css';
import { Indicator } from '@mildang/design-system/Indicator';
const IndicatorSizesExample = () => {
const [sm, setSm] = useState(0);
const [md, setMd] = useState(0);
return (
<VStack gap="24" alignItems="flex-start">
<HStack gap="16" alignItems="center">
<span className={css({ textStyle: 'title-md-medium', minWidth: '32' })}>sm</span>
<Indicator total={5} activeIndex={sm} onSelect={setSm} size="sm" />
</HStack>
<HStack gap="16" alignItems="center">
<span className={css({ textStyle: 'title-md-medium', minWidth: '32' })}>md</span>
<Indicator total={5} activeIndex={md} onSelect={setMd} size="md" />
</HStack>
</VStack>
);
};
export default IndicatorSizesExample;
예제
ActiveStates
import { VStack } from '@mildang/styled-system/jsx';
import { Indicator } from '@mildang/design-system/Indicator';
const IndicatorActiveStatesExample = () => (
<VStack gap="16" alignItems="flex-start">
{Array.from({ length: 5 }, (_, i) => (
<Indicator key={i} total={5} activeIndex={i} size="md" />
))}
</VStack>
);
export default IndicatorActiveStatesExample;
Interactive
import { useState } from 'react';
import { VStack } from '@mildang/styled-system/jsx';
import { css } from '@mildang/styled-system/css';
import { Indicator } from '@mildang/design-system/Indicator';
const IndicatorInteractiveExample = () => {
const [current, setCurrent] = useState(0);
return (
<VStack gap="16" alignItems="flex-start">
<span className={css({ textStyle: 'title-md-medium' })}>
현재 index: {current} (점을 클릭하세요)
</span>
<Indicator total={7} activeIndex={current} onSelect={setCurrent} size="md" />
</VStack>
);
};
export default IndicatorInteractiveExample;
Static
import { VStack } from '@mildang/styled-system/jsx';
import { css } from '@mildang/styled-system/css';
import { Indicator } from '@mildang/design-system/Indicator';
const IndicatorStaticExample = () => (
<VStack gap="12" alignItems="flex-start">
<span className={css({ textStyle: 'title-md-medium' })}>
onSelect 미제공 시: 클릭 불가한 정적 표시 (role="group")
</span>
<Indicator total={5} activeIndex={2} size="md" />
</VStack>
);
export default IndicatorStaticExample;
With Slider
import { useState } from 'react';
import { VStack, HStack } from '@mildang/styled-system/jsx';
import ChevronLeft from '@mildang/icons/react/chevron-left';
import { css } from '@mildang/styled-system/css';
import { token } from '@mildang/styled-system/tokens';
import ChevronRight from '@mildang/icons/react/chevron-right';
import { Indicator } from '@mildang/design-system/Indicator';
const SLIDES = [
{ title: '슬라이드 1', color: 'neutral.surface.low' },
{ title: '슬라이드 2', color: 'green.light.100' },
{ title: '슬라이드 3', color: 'orange.light.100' },
{ title: '슬라이드 4', color: 'blue.light.100' },
{ title: '슬라이드 5', color: 'red.light.100' },
] as const;
const arrowButton = css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
borderRadius: 'full',
border: 'none',
cursor: 'pointer',
backgroundColor: 'neutral.surface.low',
color: 'neutral.fill.high',
_hover: { backgroundColor: 'neutral.surface.high' },
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
});
const IndicatorWithSliderExample = () => {
const [current, setCurrent] = useState(0);
const slide = SLIDES[current];
const goPrev = () => setCurrent((c) => Math.max(0, c - 1));
const goNext = () => setCurrent((c) => Math.min(SLIDES.length - 1, c + 1));
return (
<VStack gap="16" alignItems="center">
<HStack gap="12" alignItems="center">
<button
type="button"
className={arrowButton}
onClick={goPrev}
disabled={current === 0}
aria-label="이전 슬라이드"
>
<ChevronLeft width={20} height={20} />
</button>
<div
className={css({
width: '320px',
height: '180px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textStyle: 'title-xl',
color: 'neutral.text.base',
transition: 'background-color 200ms ease',
})}
style={{ backgroundColor: token.var(`colors.${slide.color}`) }}
>
{slide.title}
</div>
<button
type="button"
className={arrowButton}
onClick={goNext}
disabled={current === SLIDES.length - 1}
aria-label="다음 슬라이드"
>
<ChevronRight width={20} height={20} />
</button>
</HStack>
<Indicator total={SLIDES.length} activeIndex={current} onSelect={setCurrent} size="md" />
<span className={css({ textStyle: 'title-md-medium', color: 'neutral.text.low' })}>
화살표든 dot이든 어느 쪽을 눌러도 같은 state를 공유해서 동기화됩니다 (current: {current})
</span>
</VStack>
);
};
export default IndicatorWithSliderExample;