Accordion

Interactive UI Explorer

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

Live Preview
평일 오후 2시 이전 결제 건은 당일 출고됩니다.

Accordion

접히는 콘텐츠 그룹. compound API(Accordion.Item, Trigger, Content)로 구성하며,type="single" 또는 "multiple"로 동작 모드를 선택합니다.
애니메이션은 grid-template-rows: 0fr ↔ 1fr 트릭으로 부드럽게.

Single (collapsible)

기본값. 한 번에 하나만 열리고, 다시 누르면 모두 닫힘 (collapsible).

Source Code
<Accordion type="single" defaultValue="shipping">
  <Accordion.Item value="shipping">
    <Accordion.Trigger>배송 안내</Accordion.Trigger>
    <Accordion.Content>...</Accordion.Content>
  </Accordion.Item>
  <Accordion.Item value="return">
    <Accordion.Trigger>반품 정책</Accordion.Trigger>
    <Accordion.Content>...</Accordion.Content>
  </Accordion.Item>
</Accordion>
평일 오후 2시 이전 결제 건은 당일 출고됩니다. 도서산간 지역은 1~2일 추가 소요됩니다.
Live Preview

Multiple

type="multiple"로 여러 개를 동시에 펼칠 수 있음 — 도움말/참조 자료 묶음에 적합.

Source Code
<Accordion type="multiple" defaultValue={["a", "c"]}>
  <Accordion.Item value="a">...</Accordion.Item>
  <Accordion.Item value="b">...</Accordion.Item>
  <Accordion.Item value="c">...</Accordion.Item>
</Accordion>
npm 또는 pnpm으로 설치 후, 진입점에서 import해 사용합니다.
자주 묻는 질문은 별도 페이지로 정리해 두었습니다.
Live Preview
Implementation

제작 코드

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

Accordiontypescript
'use client';

/**
 * Accordion — 접히는 콘텐츠 그룹.
 *
 * - compound API: <Accordion type="single" collapsible><Accordion.Item value><Accordion.Trigger /><Accordion.Content /></Accordion.Item></Accordion>
 * - type: single (한 번에 하나) / multiple (여러 개 동시)
 * - 애니메이션: grid-template-rows 0fr ↔ 1fr 트릭으로 부드러운 높이 전환 (Chrome 121+ / Safari 17.5+ / Firefox 121+)
 */

import {
  createContext,
  forwardRef,
  useCallback,
  useContext,
  useId,
  useMemo,
  useState,
  type HTMLAttributes,
  type ReactNode,
} from 'react';
import { cn } from '@/lib/cn';

export type AccordionType = 'single' | 'multiple';

interface AccordionContextValue {
  type: AccordionType;
  collapsible: boolean;
  isOpen: (value: string) => boolean;
  toggle: (value: string) => void;
}

const AccordionContext = createContext<AccordionContextValue | null>(null);

function useAccordionContext() {
  const ctx = useContext(AccordionContext);
  if (!ctx) {
    throw new Error('Accordion subcomponents must be used inside <Accordion>.');
  }
  return ctx;
}

interface ItemContextValue {
  value: string;
  open: boolean;
  contentId: string;
  triggerId: string;
}

const ItemContext = createContext<ItemContextValue | null>(null);

function useItemContext() {
  const ctx = useContext(ItemContext);
  if (!ctx) {
    throw new Error('Accordion.Trigger / Content must be used inside <Accordion.Item>.');
  }
  return ctx;
}

/* ============================================
  Root
============================================ */

interface AccordionRootSingleProps {
  type?: 'single';
  /** type="single"에서 닫힘 상태(아무것도 열리지 않음)를 허용할지 (default: true) */
  collapsible?: boolean;
  defaultValue?: string;
  value?: string;
  onValueChange?: (value: string) => void;
}

interface AccordionRootMultipleProps {
  type: 'multiple';
  defaultValue?: string[];
  value?: string[];
  onValueChange?: (value: string[]) => void;
}

export type AccordionProps = (AccordionRootSingleProps | AccordionRootMultipleProps) &
  Omit<HTMLAttributes<HTMLDivElement>, 'defaultValue' | 'onChange'> & {
    children: ReactNode;
  };

function AccordionRoot(props: AccordionProps) {
  const { type = 'single', children, className, ...rest } = props;
  const isMultiple = type === 'multiple';

  // Single mode state
  const singleProps = props as AccordionRootSingleProps;
  const [singleUncontrolled, setSingleUncontrolled] = useState<string>(
    !isMultiple ? singleProps.defaultValue ?? '' : '',
  );
  const singleIsControlled = !isMultiple && singleProps.value !== undefined;
  const singleValue = singleIsControlled ? singleProps.value ?? '' : singleUncontrolled;
  const collapsible = !isMultiple ? singleProps.collapsible ?? true : true;

  // Multiple mode state
  const multipleProps = props as AccordionRootMultipleProps;
  const [multipleUncontrolled, setMultipleUncontrolled] = useState<string[]>(
    isMultiple ? multipleProps.defaultValue ?? [] : [],
  );
  const multipleIsControlled = isMultiple && multipleProps.value !== undefined;
  const multipleValue = multipleIsControlled
    ? multipleProps.value ?? []
    : multipleUncontrolled;

  const isOpen = useCallback(
    (v: string) => (isMultiple ? multipleValue.includes(v) : singleValue === v),
    [isMultiple, multipleValue, singleValue],
  );

  const toggle = useCallback(
    (v: string) => {
      if (isMultiple) {
        const next = multipleValue.includes(v)
          ? multipleValue.filter((x) => x !== v)
          : [...multipleValue, v];
        if (!multipleIsControlled) setMultipleUncontrolled(next);
        multipleProps.onValueChange?.(next);
      } else {
        const next = singleValue === v ? (collapsible ? '' : singleValue) : v;
        if (!singleIsControlled) setSingleUncontrolled(next);
        singleProps.onValueChange?.(next);
      }
    },
    [
      isMultiple,
      multipleValue,
      multipleIsControlled,
      multipleProps,
      singleValue,
      singleIsControlled,
      singleProps,
      collapsible,
    ],
  );

  const ctx = useMemo<AccordionContextValue>(
    () => ({ type, collapsible, isOpen, toggle }),
    [type, collapsible, isOpen, toggle],
  );

  return (
    <AccordionContext.Provider value={ctx}>
      <div
        className={cn(
          'flex flex-col divide-y divide-slate-200 overflow-hidden rounded-2xl border border-slate-200 bg-white',
          className,
        )}
        {...rest}
      >
        {children}
      </div>
    </AccordionContext.Provider>
  );
}

/* ============================================
  Item
============================================ */

export interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {
  value: string;
  disabled?: boolean;
}

const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(
  ({ value, disabled, className, children, ...rest }, ref) => {
    const { isOpen } = useAccordionContext();
    const open = isOpen(value);
    const reactId = useId();

    const itemCtx = useMemo<ItemContextValue>(
      () => ({
        value,
        open,
        contentId: `${reactId}-content`,
        triggerId: `${reactId}-trigger`,
      }),
      [value, open, reactId],
    );

    return (
      <ItemContext.Provider value={itemCtx}>
        <div
          ref={ref}
          data-state={open ? 'open' : 'closed'}
          aria-disabled={disabled || undefined}
          className={cn('group/item', disabled && 'opacity-50 pointer-events-none', className)}
          {...rest}
        >
          {children}
        </div>
      </ItemContext.Provider>
    );
  },
);

AccordionItem.displayName = 'Accordion.Item';

/* ============================================
  Trigger
============================================ */

export interface AccordionTriggerProps
  extends Omit<HTMLAttributes<HTMLButtonElement>, 'children'> {
  children: ReactNode;
  /** 우측 chevron 아이콘 커스텀. */
  icon?: ReactNode;
}

const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
  ({ children, icon, className, onClick, ...rest }, ref) => {
    const { toggle } = useAccordionContext();
    const { value, open, contentId, triggerId } = useItemContext();

    return (
      <button
        ref={ref}
        type="button"
        id={triggerId}
        aria-expanded={open}
        aria-controls={contentId}
        onClick={(e) => {
          onClick?.(e);
          if (!e.defaultPrevented) toggle(value);
        }}
        className={cn(
          'flex w-full items-center justify-between gap-4 px-5 py-4 text-left text-sm font-semibold text-slate-900 transition-colors hover:bg-slate-50 focus:outline-none focus-visible:bg-slate-50',
          className,
        )}
        {...rest}
      >
        <span className="min-w-0 flex-1 truncate">{children}</span>
        <span
          aria-hidden
          className={cn(
            'inline-flex shrink-0 items-center justify-center text-slate-400 transition-transform duration-300 ease-out',
            open && 'rotate-180 text-slate-700',
          )}
        >
          {icon ?? (
            <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
              <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
            </svg>
          )}
        </span>
      </button>
    );
  },
);

AccordionTrigger.displayName = 'Accordion.Trigger';

/* ============================================
  Content — grid-rows 0fr ↔ 1fr 애니메이션
============================================ */

export interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {
  children: ReactNode;
}

const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps>(
  ({ children, className, ...rest }, ref) => {
    const { open, contentId, triggerId } = useItemContext();

    return (
      <div
        ref={ref}
        id={contentId}
        role="region"
        aria-labelledby={triggerId}
        hidden={!open}
        className={cn(
          'grid transition-[grid-template-rows] duration-300 ease-out',
          open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
          className,
        )}
        {...rest}
      >
        <div className="overflow-hidden">
          <div className="px-5 pb-4 pt-0 text-sm leading-relaxed text-slate-600">{children}</div>
        </div>
      </div>
    );
  },
);

AccordionContent.displayName = 'Accordion.Content';

/* ============================================
  Compound export — DropdownMenu 패턴
============================================ */

type AccordionType_ = typeof AccordionRoot & {
  Item: typeof AccordionItem;
  Trigger: typeof AccordionTrigger;
  Content: typeof AccordionContent;
};

export const Accordion = AccordionRoot as AccordionType_;
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;