Accordion
Vertically stacked collapsible sections.
Installation
$
npx @309-thingspire/ui@latest add accordionUsage
import { Accordion } from "@/components/accordion/accordion"<Accordion />Examples
Live preview rendered from Accordion.preview.tsx. Switch to the Code tab to view the underlying component source.
Loading preview…
import React, { useId, useMemo, useState } from 'react';
import { border, colors, radius, shadows, spacing, typography } from '../../style-tokens';
import type { AccordionProps, AccordionSize, AccordionVisualState } from './Accordion.types';
type InteractionState = 'default' | 'hover' | 'focused' | 'disabled';
type TypographyStyle = {
fontFamily: string;
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing: number;
};
type VisualStyle = {
backgroundColor: string;
borderColor: string;
boxShadow: string;
titleColor: string;
descriptionColor: string;
iconColor: string;
};
const SIZE_CONFIG: Record<
AccordionSize,
{
leadIconPaddingY: number;
titleTypography: TypographyStyle;
descriptionTypography: TypographyStyle;
}
> = {
lg: {
leadIconPaddingY: spacing.primitive['3'],
titleTypography: typography.scale.bodyM.medium,
descriptionTypography: typography.scale.bodyS.regular,
},
md: {
leadIconPaddingY: spacing.scale['2'],
titleTypography: typography.scale.bodyS.medium,
descriptionTypography: typography.scale.captionL.regular,
},
};
const palette = colors.primitive.palette;
const textTokens = colors.semantic.theme.text.base;
const iconTokens = colors.semantic.theme.icon.base;
function resolveInteractionState(
disabled: boolean,
forceState: AccordionVisualState | undefined,
hovered: boolean,
focused: boolean,
): InteractionState {
if (disabled || forceState === 'disabled') {
return 'disabled';
}
if (forceState === 'default') {
return 'default';
}
if (forceState === 'focused') {
return 'focused';
}
if (forceState === 'hover') {
return 'hover';
}
if (focused) {
return 'focused';
}
if (hovered) {
return 'hover';
}
return 'default';
}
function resolveVisualStyle(expanded: boolean, interactionState: InteractionState): VisualStyle {
if (interactionState === 'disabled') {
return {
backgroundColor: expanded ? palette.gray['1'] : palette.base.white,
borderColor: palette.gray['2a'],
boxShadow: 'none',
titleColor: textTokens.staticDarkSecondary,
descriptionColor: textTokens.staticDarkSecondary,
iconColor: iconTokens.staticDarkSecondary,
};
}
if (interactionState === 'focused') {
return {
backgroundColor: expanded ? palette.gray['1'] : palette.base.white,
borderColor: border.color.theme.action.focusLight,
boxShadow: shadows.focusRing.light.css,
titleColor: textTokens.staticDark,
descriptionColor: textTokens.staticDarkSecondary,
iconColor: iconTokens.staticDark,
};
}
if (expanded) {
return {
backgroundColor: interactionState === 'hover' ? palette.gray['2'] : palette.gray['1'],
borderColor: palette.gray['2a'],
boxShadow: shadows.elevation.xs.css,
titleColor: textTokens.staticDark,
descriptionColor: textTokens.staticDarkSecondary,
iconColor: iconTokens.staticDark,
};
}
return {
backgroundColor: interactionState === 'hover' ? palette.gray['1'] : palette.base.white,
borderColor: palette.gray['2a'],
boxShadow: 'none',
titleColor: textTokens.staticDark,
descriptionColor: textTokens.staticDarkSecondary,
iconColor: iconTokens.staticDark,
};
}
function CompassIcon({ color }: { color: string }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 20 20"
style={{
width: spacing.scale['20'],
height: spacing.scale['20'],
display: 'block',
flexShrink: 0,
color,
}}
>
<path
d="M10 20C4.477 20 0 15.523 0 10C0 4.477 4.477 0 10 0C15.523 0 20 4.477 20 10C20 15.523 15.523 20 10 20ZM10 18C12.1217 18 14.1566 17.1571 15.6569 15.6569C17.1571 14.1566 18 12.1217 18 10C18 7.87827 17.1571 5.84344 15.6569 4.34315C14.1566 2.84285 12.1217 2 10 2C7.87827 2 5.84344 2.84285 4.34315 4.34315C2.84285 5.84344 2 7.87827 2 10C2 12.1217 2.84285 14.1566 4.34315 15.6569C5.84344 17.1571 7.87827 18 10 18V18ZM13.446 7.968L7.968 13.446C7.38509 13.1013 6.89871 12.6149 6.554 12.032L12.032 6.554C12.6149 6.89871 13.1013 7.38509 13.446 7.968V7.968Z"
fill="currentColor"
/>
</svg>
);
}
function PlusIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 11.6667 11.6667"
style={{
width: spacing.scale['12'],
height: spacing.scale['12'],
display: 'block',
}}
>
<path d="M5 5V0H6.66667V5H11.6667V6.66667H6.66667V11.6667H5V6.66667H0V5H5Z" fill="currentColor" />
</svg>
);
}
function MinusIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 11.6667 1.66667"
style={{
width: spacing.scale['12'],
height: spacing.scale['2'],
display: 'block',
}}
>
<path d="M0 0H11.6667V1.66667H0V0Z" fill="currentColor" />
</svg>
);
}
export function Accordion({
title,
description,
size = 'lg',
expanded,
defaultExpanded = false,
disabled = false,
showLeadingIcon = true,
leadingIcon,
forceState,
onExpandedChange,
children,
id,
style,
...props
}: AccordionProps) {
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(defaultExpanded);
const baseId = useId().replace(/:/g, '');
const sizeConfig = SIZE_CONFIG[size];
const resolvedExpanded = expanded ?? uncontrolledExpanded;
const interactionState = resolveInteractionState(disabled, forceState, hovered, focused);
const visualStyle = useMemo(
() => resolveVisualStyle(resolvedExpanded, interactionState),
[resolvedExpanded, interactionState],
);
const rootId = id ?? `accordion-${baseId}`;
const triggerId = `${rootId}-trigger`;
const panelId = `${rootId}-panel`;
const isDisabled = interactionState === 'disabled';
const hasPanelContent = Boolean(description || children);
const onToggle = () => {
if (isDisabled) {
return;
}
const next = !resolvedExpanded;
if (expanded === undefined) {
setUncontrolledExpanded(next);
}
onExpandedChange?.(next);
};
return (
<div
id={rootId}
aria-disabled={isDisabled || undefined}
style={{
display: 'flex',
flexDirection: 'column',
gap: resolvedExpanded && hasPanelContent ? spacing.scale['4'] : spacing.scale['0'],
width: '100%',
paddingInline: spacing.scale['20'],
paddingBlock: spacing.scale['16'],
borderStyle: 'solid',
borderWidth: border.width['1'],
borderColor: visualStyle.borderColor,
borderRadius: radius.scale.lg,
backgroundColor: visualStyle.backgroundColor,
boxShadow: visualStyle.boxShadow,
overflow: 'clip',
...style,
}}
{...props}
>
<button
id={triggerId}
type="button"
aria-controls={hasPanelContent ? panelId : undefined}
aria-expanded={resolvedExpanded}
disabled={isDisabled}
onClick={onToggle}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: spacing.scale['12'],
width: '100%',
margin: spacing.scale['0'],
padding: spacing.scale['0'],
border: 'none',
background: 'transparent',
textAlign: 'left',
cursor: isDisabled ? 'not-allowed' : 'pointer',
}}
>
{showLeadingIcon ? (
<span
aria-hidden="true"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
paddingInline: spacing.scale['0'],
paddingBlock: sizeConfig.leadIconPaddingY,
flexShrink: 0,
color: visualStyle.iconColor,
}}
>
{leadingIcon ?? <CompassIcon color={visualStyle.iconColor} />}
</span>
) : null}
<span
style={{
flex: 1,
minWidth: 0,
color: visualStyle.titleColor,
fontFamily: sizeConfig.titleTypography.fontFamily,
fontSize: sizeConfig.titleTypography.fontSize,
fontWeight: sizeConfig.titleTypography.fontWeight,
lineHeight: `${sizeConfig.titleTypography.lineHeight}px`,
letterSpacing: `${sizeConfig.titleTypography.letterSpacing}px`,
}}
>
{title}
</span>
<span
aria-hidden="true"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: spacing.scale['20'],
height: spacing.scale['20'],
flexShrink: 0,
color: visualStyle.iconColor,
marginTop: spacing.scale['2'],
}}
>
{resolvedExpanded ? <MinusIcon /> : <PlusIcon />}
</span>
</button>
{resolvedExpanded && hasPanelContent ? (
<div
id={panelId}
role="region"
aria-labelledby={triggerId}
style={{
display: 'grid',
gap: spacing.scale['0'],
paddingInlineStart: showLeadingIcon ? spacing.scale['32'] : spacing.scale['0'],
}}
>
{description ? (
<p
style={{
margin: spacing.scale['0'],
color: visualStyle.descriptionColor,
fontFamily: sizeConfig.descriptionTypography.fontFamily,
fontSize: sizeConfig.descriptionTypography.fontSize,
fontWeight: sizeConfig.descriptionTypography.fontWeight,
lineHeight: `${sizeConfig.descriptionTypography.lineHeight}px`,
letterSpacing: `${sizeConfig.descriptionTypography.letterSpacing}px`,
}}
>
{description}
</p>
) : null}
{children ? <div style={{ paddingTop: spacing.scale['12'], width: '100%' }}>{children}</div> : null}
</div>
) : null}
</div>
);
}
API Reference
Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)