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

activeIndex
필수

number

지정 안 함

total
필수

number

지정 안 함

aria-label

string

지정 안 함

className

string

지정 안 함

getDotAriaLabel

(index: number) => string

지정 안 함

onSelect

(index: number) => void

지정 안 함

size

"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점 크기점 사이 간격쓰는 자리
sm6px4px카드 안 미니 캐러셀, 리스트 아이템 내부, 툴바/헤더처럼 공간이 좁은 UI
md(기본값)6px8px대부분의 모바일/데스크톱 캐러셀, 온보딩, 모달 내부

점은 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=&quot;group&quot;)
      </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;