Headless accordion component. Handles section expansion, keyboard navigation, and accessibility. Use builder pattern for custom styling.
- 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 packages/example/lib/api/naked_accordion.0.dart.
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: 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: Text(widget.body),
),
);
}
}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
NakedAccordionItemState<T> is passed to every trigger builder and
itemBuilder:
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).
Use itemBuilder when a style applies to the trigger and panel as one unit.
Its child is the fully assembled interactive trigger followed by the
transitioned panel, and its context exposes the same authoritative item state
controller used by both descendants.
NakedAccordion<String>(
value: 'details',
builder: (context, state) => Text(
state.isExpanded ? 'Hide details' : 'Show details',
),
itemBuilder: (context, _, child) {
final states =
NakedAccordionItemState.controllerOf<String>(context);
return ListenableBuilder(
listenable: states,
builder: (context, _) => DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: states.value.contains(WidgetState.focused)
? Colors.blue
: Colors.grey,
),
),
child: child,
),
);
},
child: const Text('Detailed content'),
);Keep the supplied child in the returned subtree. It retains Naked UI's pointer, keyboard, focus, transition, panel-visibility, and semantics behavior. Read the scoped controller directly instead of mirroring interaction state in a separate controller.
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 sharedWidgetStatesControllerfor observers outside buildersNakedAccordionItemState.controllerOf<T>(context)/maybeControllerOf<T>(context)→ access the authoritative controller shared by an item's trigger, panel, anditemBuilder
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()minensures at least that many sections stay expanded when closingmaxlimits how many sections can be open at once (FIFO eviction)
const NakedAccordionGroup({
Key? key,
required this.child,
required this.controller,
this.initialExpandedValues = const [],
})child→ Widget containingNakedAccordionitems; mix with any spacing widgets you needcontroller→ required state holderinitialExpandedValues→ values to open on first build when the controller is empty
const NakedAccordion({
Key? key,
required this.builder, // Recommended approach
required this.value,
required this.child,
this.itemBuilder,
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,
this.excludeSemantics = false,
})builder→NakedAccordionTriggerBuilder<T>receivingNakedAccordionItemState<T>child→ panel content displayed when expandedvalue→ unique identifier tracked by the controlleritemBuilder→ optional state-aware wrapper around the complete trigger and panel- interaction callbacks:
onFocusChange,onHoverChange,onPressChange transitionBuilderlets you wrap the panel with your own show/hide animationenabled/mouseCursor/enableFeedbackprovide interaction affordance hooksexcludeSemantics→ hide the accordion header and panel from the semantic tree
- 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