Accordion | CoUI
LogoCoUI

Accordion

아코디언/접기 컴포넌트

Accordion#

콘텐츠를 접었다 펼 수 있는 아코디언 컴포넌트입니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • FAQ, 도움말 등 질문-답변 형태의 콘텐츠를 표시할 때
  • 긴 내용을 섹션별로 접어서 공간을 절약할 때
  • 설정 패널의 카테고리별 옵션을 그룹화할 때

대신 다른 컴포넌트를 사용하세요:

  • Tabs: 관련 콘텐츠를 탭으로 전환할 때 (한 번에 하나만 보여도 될 때)
  • Dialog: 추가 정보를 모달로 보여줄 때
  • Drawer: 복잡한 콘텐츠를 사이드 패널로 보여줄 때

기본 사용법 (Basic Usage)#

// 기본 아코디언
Accordion(
  items: [
    AccordionItem(
      title: '섹션 1',
      content: Text('섹션 1의 내용입니다.'),
    ),
    AccordionItem(
      title: '섹션 2',
      content: Text('섹션 2의 내용입니다.'),
    ),
    AccordionItem(
      title: '섹션 3',
      content: Text('섹션 3의 내용입니다.'),
    ),
  ],
)

// 다중 열기 허용
Accordion(
  allowMultiple: true,
  items: accordionItems,
)

// 초기 열림 상태 — 항목별 expanded
Accordion(
  items: [
    AccordionItem(
      title: '기본 정보',
      content: Text('기본 정보 내용입니다.'),
      expanded: true,
    ),
    AccordionItem(
      title: '추가 정보',
      content: Text('추가 정보 내용입니다.'),
    ),
  ],
)
// 기본 아코디언
Accordion(
  items: [
    AccordionItem(
      title: '섹션 1',
      content: Text('섹션 1의 내용입니다.'),
    ),
    AccordionItem(
      title: '섹션 2',
      content: Text('섹션 2의 내용입니다.'),
    ),
    AccordionItem(
      title: '섹션 3',
      content: Text('섹션 3의 내용입니다.'),
    ),
  ],
)

// 다중 열기 허용
Accordion(
  allowMultiple: true,
  items: accordionItems,
)

// 초기 열림 상태 — 항목별 expanded
Accordion(
  items: [
    AccordionItem(
      title: '기본 정보',
      content: Text('기본 정보 내용입니다.'),
      expanded: true,
    ),
    AccordionItem(
      title: '추가 정보',
      content: Text('추가 정보 내용입니다.'),
    ),
  ],
)

Props / Parameters#

Accordion 파라미터#

속성타입기본값설명
items List<Widget> (Flutter) / List<Component> (Web) 필수 아코디언 항목 목록 (AccordionItem)
allowMultiple bool false 동시에 여러 항목 열기 허용
accordionStyle CoreAccordionStyle? null 인스턴스 스타일 (Style 시스템 참조)

스타일 시스템 (Style System)#

Accordion 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreAccordionStyle 단일 슬롯으로 흐릅니다. 시맨틱 enum / behaviour (items / allowMultiple) 는 위젯 파라미터로 직접 전달합니다.

시맨틱 vs 스타일#

  • 시맨틱 enum / behaviour: 위젯/컴포넌트 파라미터로 직접 (items, allowMultiple)
  • chrome / dimensional / 슬롯 스타일: CoreAccordionStyle 한 곳으로 (backgroundColor / borderColor / borderWidth / borderRadius / contentPadding / itemSpacing / dividerColor / dividerThickness / duration / arrowIconStyle / triggerStyle / contentTextStyle / chevronIconStyle / iconGapStyle / clickableStyle)

Resolve chain#

design system default for accordion
  → CoreAccordionTheme.style                   // 프로젝트 공통
  → parent component slot override
  → widget.accordionStyle                      // 인스턴스별

각 nested 슬롯 스타일 (triggerStyle / contentTextStyle / chevronIconStyle) 은 자기 컴포넌트의 자체 resolve chain 으로 다시 한 번 머지됩니다.

CoreAccordionStyle 필드#

필드타입설명
duration Duration? Expand / collapse animation duration. Visual transition timing — chrome category, lives on the style not the widget.
arrowIconStyle CoreIconStyle? Chevron / disclosure icon style override. size defers to [defaultArrowIconStyle].
backgroundColor CoreColor? Container background colour for an expanded item. Deliberately has no default* — absence is the design. The Web resolver adds the bg-* class and its inline fragment only inside if (merged.backgroundColor != null) , so a null here is what leaves the accordion transparent over whichever surface hosts it. A constant would turn that branch permanently on and give every accordion in every tree an opaque panel of its own — and only on one platform, because the Flutter widget never reads the resolved value (it paints no container fill at all, so seat (4) of the 1:1:1:1:1:1 mapping is empty for this field).
borderColor CoreColor? Outer border stroke colour. Deliberately has no default* — absence is the design. The Web resolver emits the border colour only inside if (merged.borderColor != null) , and that emit sets border-color alone — width comes from the separate [borderWidth] override, whose own default is stroke0 ([defaultBorderWidth]). A colour constant would therefore put a border-* class on every accordion to describe a stroke that has no width to draw it. The outline this component ships is the between-items rule, not a box around the whole list.
borderWidth double? Outer border stroke width (logical px).
borderRadius CoreBorderRadius? Outer border radius. Deliberately has no default* — absence is the design. The Web resolver writes border-radius only inside if (merged.borderRadius != null) . Square is correct for the shipped accordion: it has no outer fill and no outer stroke ([borderColor] / [defaultBorderWidth] above), so there is no edge for a radius to round. A constant would turn that branch permanently on, so the rule would ship on every accordion and start rounding the moment a caller sets [backgroundColor] or [borderColor] — a corner nobody asked for, arriving with an unrelated override.
contentPadding CoreEdgeInsets? Padding applied to expanded content.
itemSpacing double? Vertical spacing between accordion items (logical px) — native Column.spacing (Flutter) / row-gap (Web). When null items share a divider line instead of a gap. Deliberately has no default* — absence is the design, and it is load-bearing on both platforms. Being null is what selects the divider-separated layout: the Flutter widget branches on itemSpacing != null to decide between Column.spacing and interleaved Divider s, and the Web resolver only switches the container to display:flex; flex-direction:column; row-gap inside if (merged.itemSpacing != null) . A constant would delete the separator from every accordion that never asked for gaps — the two layouts are alternatives, so a default here is a default for the choice, not for a measurement.
dividerColor CoreColor? Divider line colour between items. Deliberately has no default* — absence delegates to the child. Both platforms treat null as "let the composed Divider supply its own colour": Flutter renders a bare const Divider unless [dividerColor] or [dividerThickness] is set, and the Web resolver returns a null divider chrome for the same condition so the widget falls back to the Divider component instead of a raw <div> . A constant would flip that pair of branches and, being equal to CoreDividerStyle.defaultColor , would restate the child's own default here — the flat-duplication half of "합성 = 재구현 금지".
dividerThickness double? Divider line thickness (logical px). Deliberately has no default* — same delegation as [dividerColor]. It is the other half of the one condition both platforms test ( dividerColor == null && dividerThickness == null ), so a constant on either field alone is enough to swap the composed Divider for a hand-built rule on every accordion, and its value would restate CoreDividerStyle.defaultThickness .
triggerStyle CoreButtonStyle? Trigger row chrome. Each rendered trigger uses Button(variant: ghost or accordion) internally; this slot tweaks chrome (paddingH / labelStyle / leadingIconStyle).
contentTextStyle CoreTextStyle? Expanded content text style (applied when the content is a plain string).
chevronIconStyle CoreIconStyle? Chevron / disclosure icon style (the rotating indicator).
iconGapStyle CoreGapStyle? 1-off spacer between the trigger label and the chevron icon — rendered by Gap (Flutter) / inline gap CSS (Web). Defers to [defaultIconGapStyle].
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot for the composed trigger Clickable (press scale / focus ring / cursor / keyboard activation). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the rest.

사용 예 (Flutter)#

Accordion(
  items: [
    AccordionItem(
      title: 'FAQ Question',
      content: Text('Answer'),
    ),
  ],
  accordionStyle: CoreAccordionStyle(
    contentPadding: CoreEdgeInsets.symmetric(vertical: CoreSpace.space24),
    dividerColor: CoreColor.token(CoreColors.outlineVariant),
    dividerThickness: CoreStrokeWidth.stroke1,
    triggerStyle: CoreButtonStyle(
      padding: CoreEdgeInsets.symmetric(vertical: CoreSpace.space16),
      labelStyle: CoreTextStyle.token(CoreTextStyles.bodyLarge),
    ),
    contentTextStyle: CoreTextStyle.token(CoreTextStyles.bodySmall),
    chevronIconStyle: CoreIconStyle(size: CoreIconSize.size20),
  ),
)

사용 예 (Web)#

Accordion(
  items: [
    AccordionItem(
      title: 'FAQ Question',
      content: Text('Answer'),
    ),
  ],
  accordionStyle: CoreAccordionStyle(
    contentPadding: CoreEdgeInsets.symmetric(vertical: CoreSpace.space24),
    dividerThickness: CoreStrokeWidth.stroke1,
    triggerStyle: CoreButtonStyle(
      padding: CoreEdgeInsets.symmetric(vertical: CoreSpace.space16),
    ),
  ),
)

AccordionItem#

속성타입기본값설명
content Widget (Flutter) / Component (Web) 필수 펼침 내용
title String? null 헤더 제목 — 기본 트리거로 자동 래핑
trigger Widget? (Flutter) / Component? (Web) null 커스텀 헤더. title 대신 사용 (둘 중 하나는 필수)
expandedboolfalse초기 펼침 여부

AccordionTrigger (Flutter)#

속성타입기본값설명
childWidget필수트리거 헤더에 표시할 내용

빠른 오버라이드 (Chain)#

이미 만든 Accordion 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 == CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class AccordionChainExample extends StatelessWidget {
  const AccordionChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        const Accordion(
          items: [
            AccordionItem(
              title: 'Is it accessible?',
              content: Text('Yes. It adheres to the WAI-ARIA design pattern.'),
              expanded: true,
            ),
            AccordionItem(
              title: 'Is it styled?',
              content: Text(
                'Yes. It comes with default styles that matches the other components.',
              ),
            ),
            AccordionItem(
              title: 'Is it animated?',
              content: Text(
                "Yes. It's animated by default, but you can disable it if you prefer.",
              ),
            ),
          ],
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor/borderWidth)까지.
        const Accordion(
          items: [
            AccordionItem(
              title: 'Is it accessible?',
              content: Text('Yes. It adheres to the WAI-ARIA design pattern.'),
              expanded: true,
            ),
            AccordionItem(
              title: 'Is it styled?',
              content: Text(
                'Yes. It comes with default styles that matches the other components.',
              ),
            ),
            AccordionItem(
              title: 'Is it animated?',
              content: Text(
                "Yes. It's animated by default, but you can disable it if you prefer.",
              ),
            ),
          ],
        ).withStyle(
          const CoreAccordionStyle(
            backgroundColor: CoreColor.token(
              CoreColors.surfaceContainerHighest,
            ),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderWidth: CoreStrokeWidth.stroke2,
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
          ),
        ),
      ],
    );
  }
}
class AccordionChainExample extends StatelessComponent {
  const AccordionChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        Accordion(
          items: [
            AccordionItem(
              title: 'Is it accessible?',
              content: Text('Yes. It adheres to the WAI-ARIA design pattern.'),
              expanded: true,
            ),
            AccordionItem(
              title: 'Is it styled?',
              content: Text(
                'Yes. It comes with default styles that matches the other components.',
              ),
            ),
            AccordionItem(
              title: 'Is it animated?',
              content: Text(
                'Yes. It\'s animated by default, but you can disable it if you prefer.',
              ),
            ),
          ],
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor/borderWidth)까지.
        Accordion(
          items: [
            AccordionItem(
              title: 'Is it accessible?',
              content: Text('Yes. It adheres to the WAI-ARIA design pattern.'),
              expanded: true,
            ),
            AccordionItem(
              title: 'Is it styled?',
              content: Text(
                'Yes. It comes with default styles that matches the other components.',
              ),
            ),
            AccordionItem(
              title: 'Is it animated?',
              content: Text(
                'Yes. It\'s animated by default, but you can disable it if you prefer.',
              ),
            ),
          ],
        ).withStyle(
          const CoreAccordionStyle(
            backgroundColor: CoreColor.token(
              CoreColors.surfaceContainerHighest,
            ),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderWidth: CoreStrokeWidth.stroke2,
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

변형 (Variants)#

아이콘 포함 (커스텀 트리거)#

title 은 문자열 전용이라, 헤더에 아이콘을 넣으려면 trigger 슬롯에 직접 조립합니다. Flutter 는 AccordionTrigger 로 감싸면 토글·포커스·chevron 이 그대로 따라오고, Web 의 trigger 는 임의 Component 라 필요한 인터랙션을 직접 붙입니다.

// Flutter
AccordionItem(
  trigger: AccordionTrigger(
    child: Row(
      children: [
        Icon(LucideIcons.user),
        Gap(gapStyle: CoreGapStyle(size: CoreSpace.space8)),
        Text('개인 정보').bodyMedium.onSurface,
      ],
    ),
  ),
  content: PersonalInfoForm(),
)

FAQ 스타일#

Accordion(
  items: faqList.map((faq) => AccordionItem(
    title: faq.question,
    content: Text(faq.answer),
  )).toList(),
)

단일 항목 (접기/펼치기)#

AccordionItem(
  title: '고급 설정',
  expanded: false,
  content: AdvancedSettingsPanel(),
)

동작 스펙 (Behavior)#

열기/닫기#

  • 헤더 클릭 시 해당 섹션이 슬라이드 애니메이션으로 펼쳐짐/접힘
  • allowMultiple: false (기본): 하나를 열면 이전에 열린 항목이 자동으로 닫힘
  • allowMultiple: true: 여러 항목을 동시에 열 수 있음

애니메이션#

  • 기본 CoreDuration.normal (200ms), easeInOut 커브
  • 시간은 accordionStyle.duration 으로 조정합니다 (프로젝트 공통은 CoreAccordionTheme.style.duration)
Accordion(
  items: accordionItems,
  accordionStyle: CoreAccordionStyle(
    duration: Duration(milliseconds: CoreDuration.moderate),
  ),
)

화살표 아이콘#

  • 헤더 우측의 chevron 아이콘이 열림/닫힘 방향을 표시 (180° 회전 트랜지션)
  • 크기·색은 accordionStyle.chevronIconStyle (기본값은 arrowIconStyle) 로 조정합니다
  • 아이콘 글리프 자체를 바꾸려면 trigger 슬롯에 헤더를 직접 조립합니다

사용 가이드라인 (Usage Guidelines)#

✅ Do#

자주 묻는 질문에 아코디언을 활용하세요.

Accordion(
  items: faqList.map((faq) => AccordionItem(
    title: faq.question,
    content: Text(faq.answer),
  )).toList(),
)

질문을 훑어보고 원하는 답변만 펼쳐 볼 수 있습니다.


❌ Don't#

필수로 봐야 하는 내용을 아코디언에 숨기지 마세요.

Accordion(items: [
  AccordionItem(
    title: '결제 정보',
    content: PaymentForm(), // 반드시 입력해야 하는 폼
  ),
])

중요한 내용이 접혀 있으면 사용자가 놓칠 수 있습니다.

✅ Do#

섹션 수가 많으면 아코디언을 사용하세요.

Accordion(
  items: categories.map((cat) => AccordionItem(
    title: cat.name,
    content: cat.settingsPanel,
  )).toList(),
)

설정 페이지처럼 많은 섹션을 한 페이지에 담을 수 있습니다.


❌ Don't#

항목이 2개 이하이면 아코디언을 사용하지 마세요.

Accordion(items: [
  AccordionItem(title: '유일한 섹션', content: onlySection),
])

항목이 적으면 접기/펼치기가 불필요한 단계입니다. 그냥 보여주세요.

접근성 (Accessibility)#

키보드 인터랙션#

동작
Enter / Space포커스된 헤더 열기/닫기
Tab다음 아코디언 헤더로 이동
Shift+Tab이전 아코디언 헤더로 이동

스크린 리더#

양 플랫폼 모두 헤더는 Clickable 합성이며, 펼침 상태를 스크린 리더에 전달합니다.

  • Flutter: CoUISemantics(expanded: ...) 로 "펼침/접힘" 상태가 전달됩니다.
  • Web: 헤더는 네이티브 <button> 이 아니라 Clickable<div> 이며, role="button" + tabindex="0" + aria-expanded 를 속성으로 얹습니다. 포커스와 Enter / Space 활성화는 Clickable 이 직접 구현합니다 (브라우저 네이티브 버튼 동작이 아님).

터치 타겟#

  • 헤더는 행 전체가 히트 영역이라 24×24 최소치(→ 전역 접근성 축)를 실질적으로 넘어선다. 헤더가 실제로 그리는 높이는 패딩과 라벨 타이포그래피가 정하며 고정값이 아니다.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
슬롯 타입 items: List<Widget> / content: Widget items: List<Component> / content: Component
커스텀 헤더 trigger: Widget? (AccordionTrigger 로 감싸면 기본 인터랙션 포함) trigger: Component?
펼침 애니메이션 SizeTransition + AnimationController CSS grid-rows 트랜지션
접힌 패널 격리ExcludeFocusinert 속성
  • Tabs: 콘텐츠를 탭으로 전환. 아코디언과 달리 한 번에 하나의 패널만 표시
  • Card: 콘텐츠 컨테이너. 아코디언 항목 내부에 카드를 배치 가능

조합 예제#

// 설정 페이지 패턴
Accordion(
  allowMultiple: true,
  items: [
    AccordionItem(
      title: '프로필 설정',
      content: ProfileSettingsForm(),
    ),
    AccordionItem(
      title: '알림 설정',
      content: NotificationSettingsForm(),
    ),
    AccordionItem(
      title: '보안 설정',
      content: SecuritySettingsForm(),
    ),
  ],
)