Accordion#
콘텐츠를 접었다 펼 수 있는 아코디언 컴포넌트입니다.
Live Preview#
class AccordionDefaultExample extends StatelessComponent {
const AccordionDefaultExample({super.key});
@override
Component build(BuildContext context) {
return 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.'),
),
],
);
}
}
class AccordionDefaultExample extends StatelessWidget {
const AccordionDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return 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.",
),
),
],
);
}
}
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',
);
}
}
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),
),
),
],
);
}
}
사용 시기 (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 대신 사용 (둘 중 하나는 필수) |
expanded | bool | false | 초기 펼침 여부 |
AccordionTrigger (Flutter)#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child | Widget | 필수 | 트리거 헤더에 표시할 내용 |
빠른 오버라이드 (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)#
| 항목 | Flutter | Web |
|---|---|---|
| 슬롯 타입 | items: List<Widget> / content: Widget |
items: List<Component> / content: Component |
| 커스텀 헤더 | trigger: Widget? (AccordionTrigger 로 감싸면 기본 인터랙션 포함) |
trigger: Component? |
| 펼침 애니메이션 | SizeTransition + AnimationController |
CSS grid-rows 트랜지션 |
| 접힌 패널 격리 | ExcludeFocus | inert 속성 |
관련 컴포넌트 (Related Components)#
조합 예제#
// 설정 페이지 패턴
Accordion(
allowMultiple: true,
items: [
AccordionItem(
title: '프로필 설정',
content: ProfileSettingsForm(),
),
AccordionItem(
title: '알림 설정',
content: NotificationSettingsForm(),
),
AccordionItem(
title: '보안 설정',
content: SecuritySettingsForm(),
),
],
)