Accordion
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
평일 오후 2시 이전 결제 건은 당일 출고됩니다.
수령 후 7일 이내 단순 변심 반품이 가능합니다.
동일 상품의 사이즈/색상 교환만 가능합니다.
Accordion
접히는 콘텐츠 그룹. compound API(Accordion.Item, Trigger, Content)로 구성하며,type="single" 또는 "multiple"로 동작 모드를 선택합니다.
애니메이션은 grid-template-rows: 0fr ↔ 1fr 트릭으로 부드럽게.
Single (collapsible)
기본값. 한 번에 하나만 열리고, 다시 누르면 모두 닫힘 (collapsible).
Source Code
평일 오후 2시 이전 결제 건은 당일 출고됩니다. 도서산간 지역은 1~2일 추가 소요됩니다.
상품 수령 후 7일 이내 단순 변심 반품이 가능합니다. 단, 사용 흔적이 있는 경우 제한될 수 있습니다.
동일 상품의 사이즈/색상 교환만 가능하며, 다른 상품으로의 교환은 반품 후 재구매로 진행됩니다.
Live Preview
Multiple
type="multiple"로 여러 개를 동시에 펼칠 수 있음 — 도움말/참조 자료 묶음에 적합.
Source Code
npm 또는 pnpm으로 설치 후, 진입점에서 import해 사용합니다.
모든 옵션은 props로 전달됩니다. variant, size, 색상 토큰 모두 자유롭게.
자주 묻는 질문은 별도 페이지로 정리해 두었습니다.
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;