NakedAccordion
Headless accordion primitives with expansion state management and typed builders for fully custom triggers
Headless accordion component. Handles section expansion, keyboard navigation, and accessibility. Use builder pattern for custom styling.
When to use this
- FAQ sections: Expandable questions and answers
- Settings groups: Organize related configuration options
- Content organization: Break long content into scannable sections
- Space-saving layouts: Show/hide content sections as needed
A complete example lives in example/lib/api/naked_accordion.0.dart
.
Basic implementation
import 'package:flutter/material.dart';
import 'package:naked_ui/naked_ui.dart';
class AccordionExample extends StatefulWidget {
const AccordionExample({super.key});
@override
State<AccordionExample> createState() => _AccordionExampleState();
}
class _AccordionExampleState extends State<AccordionExample> {
final controller = NakedAccordionController<String>(min: 1, max: 2);
@override
Widget build(BuildContext context) {
return NakedAccordionGroup<String>(
controller: controller,
initialExpandedValues: const ['intro'],
children: const [
_AccordionSection(
value: 'intro',
title: 'Introduction',
body: 'Foundational information about the topic.',
),
SizedBox(height: 8),
_AccordionSection(
value: 'details',
title: 'Details',
body: 'Deep-dive content that can be long-form text.',
),
],
);
}
}
class _AccordionSection extends StatefulWidget {
const _AccordionSection({
required this.value,
required this.title,
required this.body,
});
final String value;
final String title;
final String body;
@override
State<_AccordionSection> createState() => _AccordionSectionState();
}
class _AccordionSectionState extends State<_AccordionSection> {
@override
Widget build(BuildContext context) {
return NakedAccordion<String>(
value: widget.value,
builder: (context, state) {
final bool isExpanded = state.isExpanded;
final bool canExpand = state.canExpand;
final bool canCollapse = state.canCollapse;
final bool isHovered = state.isHovered;
return AnimatedContainer(
duration: const Duration(milliseconds: 160),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: isExpanded || isHovered ? Colors.grey.shade100 : Colors.white,
border: Border.all(
color: !canExpand
? Colors.grey.shade400
: !canCollapse
? Colors.grey.shade200
: Colors.grey.shade300,
),
),
child: Row(
children: [
Expanded(
child: const Text(
widget.title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
AnimatedRotation(
turns: isExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 160),
child: const Icon(Icons.keyboard_arrow_down_rounded),
),
],
),
);
},
transitionBuilder: (panel) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) => SizeTransition(
axisAlignment: 1,
sizeFactor: animation,
child: child,
),
child: panel,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: const Text(widget.body),
),
);
}
}
Group State Snapshot
NakedAccordionGroupState
is provided by NakedAccordionGroup
:
expandedCount
→ number of currently expanded itemscanExpandMore
/canCollapseMore
→ whether additional opens/closes are allowed under the controller constraintsminExpanded
/maxExpanded
→ configuration echoed for conveniencewidgetStates
→ hover/focus/pressed/disabled states captured from the group container
Item State Snapshot
NakedAccordionItemState<T>
is passed to every trigger builder:
value
→ item identifierisExpanded
→ whether the panel is currently opencanExpand
/canCollapse
→ controller-aware capabilitieswidgetStates
→ rawSet<WidgetState>
to resolve hover, focus, press
Use these values to adjust affordances (e.g. disable icons when the controller prevents expansion).
Access from Context
NakedAccordionGroupState.of(context)
/maybeOf(context)
→ read the nearest group stateNakedAccordionItemState.of<T>(context)
/maybeOf<T>(context)
→ read the nearest item stateNakedAccordionGroupState.controllerOf(context)
/maybeControllerOf(context)
→ access the sharedWidgetStatesController
for observers outside builders
Controller Behaviour
NakedAccordionController<T>
exposes:
- Constructor:
NakedAccordionController({int min = 0, int? max})
values
→LinkedHashSet<T>
with insertion order (oldest → newest)open(value)
,close(value)
,toggle(value)
openAll(Iterable<T>)
,replaceAll(Iterable<T>)
,clear()
min
ensures at least that many sections stay expanded when closingmax
limits how many sections can be open at once (FIFO eviction)
NakedAccordionGroup
const NakedAccordionGroup({
Key? key,
required this.children,
required this.controller,
this.initialExpandedValues = const [],
})
children
→ list of Widgets; mixNakedAccordion
with any spacing widgets you needcontroller
→ required state holderinitialExpandedValues
→ values to open on first build when the controller is empty
NakedAccordion
const NakedAccordion({
Key? key,
required this.builder, // Recommended approach
required this.value,
required this.child,
this.transitionBuilder,
this.enabled = true,
this.mouseCursor = SystemMouseCursors.click,
this.enableFeedback = true,
this.autofocus = false,
this.focusNode,
this.onFocusChange,
this.onHoverChange,
this.onPressChange,
this.semanticLabel,
})
builder
→NakedAccordionTriggerBuilder<T>
receivingNakedAccordionItemState<T>
child
→ panel content displayed when expandedvalue
→ unique identifier tracked by the controller- interaction callbacks:
onFocusChange
,onHoverChange
,onPressChange
transitionBuilder
lets you wrap the panel with your own show/hide animationenabled
/mouseCursor
/enableFeedback
provide interaction affordance hooks
Accessibility Guidance
- Headers expose button semantics and support keyboard activation via Space/Enter
- Arrow keys move focus between headers when wrapped in traversal groups (handled automatically)
- Provide clear focus styles through
state.isFocused
- Panel content should include headings or text to clarify context for screen readers