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 items
  • canExpandMore / canCollapseMore → whether additional opens/closes are allowed under the controller constraints
  • minExpanded / maxExpanded → configuration echoed for convenience
  • widgetStates → hover/focus/pressed/disabled states captured from the group container

Item State Snapshot

NakedAccordionItemState<T> is passed to every trigger builder:

  • value → item identifier
  • isExpanded → whether the panel is currently open
  • canExpand / canCollapse → controller-aware capabilities
  • widgetStates → raw Set<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 state
  • NakedAccordionItemState.of<T>(context) / maybeOf<T>(context) → read the nearest item state
  • NakedAccordionGroupState.controllerOf(context) / maybeControllerOf(context) → access the shared WidgetStatesController for observers outside builders

Controller Behaviour

NakedAccordionController<T> exposes:

  • Constructor: NakedAccordionController({int min = 0, int? max})
  • valuesLinkedHashSet<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 closing
  • max limits how many sections can be open at once (FIFO eviction)

Constructors

NakedAccordionGroup

const NakedAccordionGroup({
  Key? key,
  required this.children,
  required this.controller,
  this.initialExpandedValues = const [],
})
  • children → list of Widgets; mix NakedAccordion with any spacing widgets you need
  • controller → required state holder
  • initialExpandedValues → 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,
})
  • builderNakedAccordionTriggerBuilder<T> receiving NakedAccordionItemState<T>
  • child → panel content displayed when expanded
  • value → unique identifier tracked by the controller
  • interaction callbacks: onFocusChange, onHoverChange, onPressChange
  • transitionBuilder lets you wrap the panel with your own show/hide animation
  • enabled / 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