Breadcrumbs

Interactive UI Explorer

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

Live Preview

Breadcrumbs

페이지 위계 네비게이션. items 배열의 마지막 항목(href 없음)이 현재 페이지로 자동 표시됩니다. maxItems로 중간 항목을 ... 로 축약할 수 있습니다.

Basic

가장 일반적인 형태. 마지막 항목이 현재 페이지로 강조됩니다.

Source Code
<Breadcrumbs
  items={[
    { label: '홈', href: '/' },
    { label: '쇼케이스', href: '/showcase' },
    { label: '현재 페이지' },
  ]}
/>
Live Preview

Custom separator

separator props로 구분자 교체.

Source Code
<Breadcrumbs
  separator="/"
  items={[
    { label: '관리자', href: '/admin' },
    { label: '컴포넌트', href: '/admin/components' },
    { label: '편집' },
  ]}
/>
Live Preview

Truncated (maxItems)

긴 경로는 maxItems로 축약 — 첫 항목 + ... + 끝쪽 (maxItems-1)개.

Source Code
<Breadcrumbs
  maxItems={3}
  items={[
    { label: '루트' },
    { label: '카테고리' },
    { label: '하위' },
    { label: '세부' },
    { label: '현재' },
  ]}
/>
Live Preview
Implementation

제작 코드

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

Breadcrumbstypescript
'use client';

/**
 * Breadcrumbs — 페이지 위계 네비게이션.
 *
 * - items 배열 — 마지막 항목(href 없음)이 현재 페이지로 자동 표시
 * - separator 커스텀 가능 (default: ›)
 * - maxItems 초과 시 중간을 ... 으로 축약
 */

import Link from 'next/link';
import { Fragment, forwardRef, type HTMLAttributes, type ReactNode } from 'react';
import { cn } from '@/lib/cn';

export interface BreadcrumbItem {
  label: ReactNode;
  /** 없으면 현재 페이지(non-link)로 표시 */
  href?: string;
  /** 좌측 아이콘 (예: 홈 아이콘) */
  icon?: ReactNode;
}

export type BreadcrumbsTheme = 'light' | 'dark';

export interface BreadcrumbsProps extends HTMLAttributes<HTMLElement> {
  items: BreadcrumbItem[];
  /** 구분자 (default: › ) */
  separator?: ReactNode;
  /** maxItems 지정 시 중간 항목들을 ... 으로 축약 (첫·마지막은 항상 노출). default 무한 */
  maxItems?: number;
  /** 색상 토큰 — light(기본, 흰 배경용) / dark(slate-900 이상 어두운 배경용) */
  theme?: BreadcrumbsTheme;
}

const themeStyles: Record<
  BreadcrumbsTheme,
  {
    separator: string;
    ellipsis: string;
    current: string;
    link: string;
  }
> = {
  light: {
    separator: 'text-slate-300',
    ellipsis: 'text-slate-400',
    current: 'text-slate-900',
    link: 'text-slate-500 hover:text-slate-900',
  },
  dark: {
    separator: 'text-slate-600',
    ellipsis: 'text-slate-500',
    current: 'text-slate-100',
    link: 'text-slate-400 hover:text-slate-200',
  },
};

function DefaultSeparator({ colorClass }: { colorClass: string }) {
  return (
    <svg
      className={cn('size-3.5 shrink-0', colorClass)}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      aria-hidden
    >
      <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
    </svg>
  );
}

export const Breadcrumbs = forwardRef<HTMLElement, BreadcrumbsProps>(
  ({ items, separator, maxItems, theme = 'light', className, ...rest }, ref) => {
    const t = themeStyles[theme];
    const sep = separator ?? <DefaultSeparator colorClass={t.separator} />;

    /** maxItems 축약 — 첫 1개 + ... + 끝쪽 (maxItems-1)개 */
    let displayItems: (BreadcrumbItem | { ellipsis: true })[] = items;
    if (maxItems && items.length > maxItems && maxItems >= 2) {
      const tailCount = maxItems - 1;
      displayItems = [items[0], { ellipsis: true }, ...items.slice(items.length - tailCount)];
    }

    return (
      <nav
        ref={ref}
        aria-label="Breadcrumb"
        className={cn('flex w-full items-center text-sm', className)}
        {...rest}
      >
        <ol className="flex flex-wrap items-center gap-1.5">
          {displayItems.map((item, idx) => {
            const isLast = idx === displayItems.length - 1;
            const key = idx;

            if ('ellipsis' in item) {
              return (
                <Fragment key={`ellipsis-${key}`}>
                  <li className="flex items-center">
                    <span
                      aria-hidden
                      className={cn('inline-flex size-6 items-center justify-center rounded', t.ellipsis)}
                    >
                      …
                    </span>
                  </li>
                  {!isLast && <li aria-hidden>{sep}</li>}
                </Fragment>
              );
            }

            const isCurrent = isLast || !item.href;
            const content = (
              <span className="inline-flex items-center gap-1.5">
                {item.icon && (
                  <span className="inline-flex size-3.5 items-center justify-center" aria-hidden>
                    {item.icon}
                  </span>
                )}
                <span className="truncate">{item.label}</span>
              </span>
            );

            return (
              <Fragment key={key}>
                <li className="flex items-center">
                  {isCurrent ? (
                    <span
                      aria-current="page"
                      className={cn('inline-flex max-w-[16rem] items-center font-semibold', t.current)}
                    >
                      {content}
                    </span>
                  ) : (
                    <Link
                      href={item.href!}
                      className={cn(
                        'inline-flex max-w-[12rem] items-center font-medium underline-offset-4 transition-colors hover:underline',
                        t.link,
                      )}
                    >
                      {content}
                    </Link>
                  )}
                </li>
                {!isLast && <li aria-hidden>{sep}</li>}
              </Fragment>
            );
          })}
        </ol>
      </nav>
    );
  },
);

Breadcrumbs.displayName = 'Breadcrumbs';