Fliin
On this page

Components / data-display

Accordion

Groups sections that open one at a time or together from their header, with a spring height, a content fade and a turning chevron.

16 of 16 variants pass
Accordion, multiple state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add accordionAlso installs: core, icons, surface, separator

Examples

Multiple

With FlAccordionSelection.multiple, several answers stay open at once; initialExpanded names the ones open at first.

Accordion, multiple state
import 'package:your_app/ui/fliin/components/accordion/fl_accordion.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlAccordion<String>(
      selection: FlAccordionSelection.multiple,
      initialExpanded: const {'order', 'ship'},
      items: [
          FlAccordionItem(
            value: 'order',
            title: 'How do I place an order?',
            content: const FlText('Orders ship within two business days. You get a tracking link by email.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'pay',
            title: 'What payment methods do you accept?',
            content: const FlText('Card, Apple Pay and PayPal.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'ship',
            title: 'How much does shipping cost?',
            content: const FlText('Free from 50 €.', tone: FlTextTone.muted),
          ),
      ],
    );
  }
}

Surface

The surface variant sets the questions on a raised card, for an accordion that stands alone on the page.

Accordion, surface state
import 'package:your_app/ui/fliin/components/accordion/fl_accordion.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlAccordion<String>(
      variant: FlAccordionVariant.surface,
      initialExpanded: const {'order'},
      items: [
          FlAccordionItem(
            value: 'order',
            title: 'How do I place an order?',
            content: const FlText('Orders ship within two business days. You get a tracking link by email.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'pay',
            title: 'What payment methods do you accept?',
            content: const FlText('Card, Apple Pay and PayPal.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'ship',
            title: 'How much does shipping cost?',
            content: const FlText('Free from 50 €.', tone: FlTextTone.muted),
          ),
      ],
    );
  }
}

With icons

An icon before each question, through leading, helps the eye find a topic in a long list.

Accordion, with-icons state
import 'package:your_app/ui/fliin/components/accordion/fl_accordion.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlAccordion<String>(
      initialExpanded: const {'order'},
      items: [
          FlAccordionItem(
            value: 'order',
            leading: FlIcon(FlIcons.shoppingCart),
            title: 'How do I place an order?',
            content: const FlText('Orders ship within two business days. You get a tracking link by email.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'pay',
            leading: FlIcon(FlIcons.lock),
            title: 'What payment methods do you accept?',
            content: const FlText('Card, Apple Pay and PayPal.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'ship',
            leading: FlIcon(FlIcons.mapPin),
            title: 'How much does shipping cost?',
            content: const FlText('Free from 50 €.', tone: FlTextTone.muted),
          ),
      ],
    );
  }
}

Custom indicator

indicator replaces the turning chevron; with expandedIndicator, the two cross-fade as the section opens and closes: a plus, then a minus.

Accordion, custom-indicator state
import 'package:your_app/ui/fliin/components/accordion/fl_accordion.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlAccordion<String>(
      variant: FlAccordionVariant.surface,
      initialExpanded: const {'plus'},
      items: [
        FlAccordionItem(
          value: 'plus',
          title: 'Using a plus and a minus',
          indicator: FlIcon(FlIcons.plus),
          expandedIndicator: FlIcon(FlIcons.minus),
          content: const FlText(
            'The plus gives way to a minus once the section is open.',
            tone: FlTextTone.muted,
          ),
        ),
        FlAccordionItem(
          value: 'chevron',
          title: 'Using the default chevron',
          content: const FlText(
            'Without an indicator, the chevron turns half a turn.',
            tone: FlTextTone.muted,
          ),
        ),
      ],
    );
  }
}

Custom styles

A setup checklist: a tinted tile as leading, a subtitle under each title that says what the step holds, on the surface variant.

Accordion, custom-styles state
import 'package:your_app/ui/fliin/components/accordion/fl_accordion.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlAccordion<String>(
      variant: FlAccordionVariant.surface,
      items: [
        for (final (value, icon, title, subtitle, content) in [
          (
            'notifications',
            FlIcons.bell,
            'Set up notifications',
            'Receive account activity updates',
            'Stay informed about your account activity in real time.',
          ),
          (
            'calendar',
            FlIcons.calendar,
            'Connect your calendar',
            'Sync your bookings both ways',
            'Your bookings appear in your calendar as soon as they are made.',
          ),
          (
            'profile',
            FlIcons.person,
            'Complete your profile',
            'Add a photo and a short bio',
            'A complete profile gets three times more replies.',
          ),
        ])
          FlAccordionItem(
            value: value,
            leading: _Tile(icon: icon),
            title: title,
            subtitle: subtitle,
            content: FlText(content, tone: FlTextTone.muted),
          ),
      ],
    );
  }
}

/// An icon on an accent tint, the size of a control.
class _Tile extends StatelessWidget {
  const _Tile({required this.icon});

  final FlIconData icon;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    return DecoratedBox(
      decoration: ShapeDecoration(
        color: theme.colors.accentSoft,
        shape: FlContinuousBorder(radius: theme.shape.radiusXl),
      ),
      child: SizedBox.square(
        dimension: theme.size.controlCompact,
        child: Center(
          child: FlIcon(
            icon,
            size: theme.size.buttonIconLg,
            color: theme.colors.onAccentSoft,
          ),
        ),
      ),
    );
  }
}

Faq

A help page: a heading, then one surface accordion per category under its name.

Accordion, faq state
import 'package:your_app/ui/fliin/components/accordion/fl_accordion.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  static const _categories = [
    (
      'General',
      [
        (
          'How do I place an order?',
          'Add items to your cart, then check out with your shipping and '
              'payment details.',
        ),
        (
          'Can I cancel my order?',
          'Yes, until it ships. After that, you can return it within 30 days.',
        ),
      ],
    ),
    (
      'Support',
      [
        (
          'How do I get support?',
          'Write to us from the contact form; we answer within a day.',
        ),
      ],
    ),
  ];

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: space.xl,
      children: [
        Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          spacing: space.xs,
          children: const [
            FlText('Frequently asked questions', type: FlTextType.h4),
            FlText(
              'Everything you need to know about orders.',
              tone: FlTextTone.muted,
            ),
          ],
        ),
        for (final (category, questions) in _categories)
          Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            spacing: space.sm,
            children: [
              FlText(category, type: FlTextType.bodySm, tone: FlTextTone.muted),
              FlAccordion<String>(
                variant: FlAccordionVariant.surface,
                items: [
                  for (final (question, answer) in questions)
                    FlAccordionItem(
                      value: question,
                      title: question,
                      content: FlText(answer, tone: FlTextTone.muted),
                    ),
                ],
              ),
            ],
          ),
      ],
    );
  }
}

Collapsed

Every answer folded: the questions alone, for a page that should read at a glance.

Accordion, collapsed state
import 'package:your_app/ui/fliin/components/accordion/fl_accordion.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlAccordion<String>(
      items: [
          FlAccordionItem(
            value: 'order',
            title: 'How do I place an order?',
            content: const FlText('Orders ship within two business days. You get a tracking link by email.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'pay',
            title: 'What payment methods do you accept?',
            content: const FlText('Card, Apple Pay and PayPal.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'ship',
            title: 'How much does shipping cost?',
            content: const FlText('Free from 50 €.', tone: FlTextTone.muted),
          ),
      ],
    );
  }
}

Expanded

One answer open from the start, through initialExpanded; opening another closes it.

Accordion, expanded state
import 'package:your_app/ui/fliin/components/accordion/fl_accordion.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlAccordion<String>(
      initialExpanded: const {'pay'},
      items: [
          FlAccordionItem(
            value: 'order',
            title: 'How do I place an order?',
            content: const FlText('Orders ship within two business days. You get a tracking link by email.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'pay',
            title: 'What payment methods do you accept?',
            content: const FlText('Card, Apple Pay and PayPal.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'ship',
            title: 'How much does shipping cost?',
            content: const FlText('Free from 50 €.', tone: FlTextTone.muted),
          ),
      ],
    );
  }
}

Disabled

enabled: false keeps the questions visible but lets no one open them.

Accordion, disabled state
import 'package:your_app/ui/fliin/components/accordion/fl_accordion.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlAccordion<String>(
      enabled: false,
      items: [
          FlAccordionItem(
            value: 'order',
            title: 'How do I place an order?',
            content: const FlText('Orders ship within two business days. You get a tracking link by email.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'pay',
            title: 'What payment methods do you accept?',
            content: const FlText('Card, Apple Pay and PayPal.', tone: FlTextTone.muted),
          ),
          FlAccordionItem(
            value: 'ship',
            title: 'How much does shipping cost?',
            content: const FlText('Free from 50 €.', tone: FlTextTone.muted),
          ),
      ],
    );
  }
}

Usage

The code the reference images render:

final example = ColoredBox(
  color: FlScope.of(context).colors.surface,
  child: Padding(
    padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.xl,
      children: [
        FlAccordion<String>(
          initialExpanded: const {'b'},
          items: [
            FlAccordionItem(
              value: 'a',
              leading: FlIcon(FlIcons.shoppingCart),
              title: 'How do I order?',
              content: Text('Add to cart, then pay.'),
            ),
            FlAccordionItem(
              value: 'b',
              leading: FlIcon(FlIcons.lock),
              title: 'Payment methods',
              content: Text('Card, Apple Pay and PayPal.'),
            ),
            FlAccordionItem(
              value: 'c',
              leading: FlIcon(FlIcons.mapPin),
              title: 'Shipping fees',
              content: Text('Free from €50.'),
              enabled: false,
            ),
          ],
        ),
        FlAccordion<String>(
          variant: FlAccordionVariant.surface,
          initialExpanded: const {'a'},
          items: [
            FlAccordionItem(
              value: 'a',
              title: 'How do I order?',
              content: Text('Add to cart, then pay.'),
            ),
            FlAccordionItem(
              value: 'b',
              title: 'Payment methods',
              content: Text('Card, Apple Pay and PayPal.'),
            ),
            FlAccordionItem(
              value: 'c',
              title: 'Shipping fees',
              content: Text('Free from €50.'),
            ),
          ],
        ),
      ],
    ),
  ),
);

API

FlAccordionVariant: enum {plain, surface}
FlAccordionVariant.plain: static final FlAccordionVariant
FlAccordionVariant.surface: static final FlAccordionVariant
FlAccordionSelection: enum {single, multiple}
FlAccordionSelection.single: static final FlAccordionSelection
FlAccordionSelection.multiple: static final FlAccordionSelection
FlAccordionItem: final class<T>
FlAccordionItem.new: const new({required T value, required String title, required Widget content, List<InlineSpan>? titleSpans, Widget? leading, Widget? indicator, bool enabled, String? subtitle, Widget? expandedIndicator})
FlAccordionItem.value: get T
FlAccordionItem.title: get String
FlAccordionItem.titleSpans: get List<InlineSpan>?
FlAccordionItem.content: get Widget
FlAccordionItem.leading: get Widget?
FlAccordionItem.indicator: get Widget?
FlAccordionItem.enabled: get bool
FlAccordionItem.subtitle: get String?
FlAccordionItem.expandedIndicator: get Widget?
FlAccordion: class<T> extends StatefulWidget
FlAccordion.new: const new({super.key, required List<FlAccordionItem<T>> items, FlAccordionVariant variant, FlAccordionSelection selection, bool collapsible, Set<T>? expanded, Set<T> initialExpanded, ValueChanged<Set<T>>? onExpandedChanged, bool enabled, bool separators})
FlAccordion.items: get List<FlAccordionItem<T>>
FlAccordion.variant: get FlAccordionVariant
FlAccordion.selection: get FlAccordionSelection
FlAccordion.collapsible: get bool
FlAccordion.expanded: get Set<T>?
FlAccordion.initialExpanded: get Set<T>
FlAccordion.onExpandedChanged: get ValueChanged<Set<T>>?
FlAccordion.enabled: get bool
FlAccordion.separators: get bool
FlAccordion.createState: State<FlAccordion<T>>()

Reference renders

The 16 images published with version 0.2.5, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify accordion compares your copy to them.

iOS · Light
Accordion, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Accordion, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Accordion, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Accordion, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Accordion, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Accordion, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Accordion, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Accordion, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Accordion, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Accordion, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Accordion, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Accordion, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Accordion, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Accordion, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Accordion, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Accordion, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • show a collapsible list of frequent questions
  • hide the details of a section behind a tap
  • keep advanced settings folded away
  • show a single open answer at a time
  • open several detail sections together
  • hide shipping and returns details in collapsible panels
Not for
  • navigation between sibling views: use tabs (tabs)
  • a list of settings that open a screen: use a list row (list-row)
States
  • Collapsed
  • Expanded
  • Disabled

Source

The files fliin add accordion copies into your app, before your prefix and imports are applied.

accordion/fl_accordion.dart

// Modified from the Accordion of HeroUI Native 1.0.10, Copyright 2025 NextUI
// Inc., licensed under Apache 2.0: https://www.apache.org/licenses/LICENSE-2.0
import 'dart:math' as math;

import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/separator/fl_separator.dart';
import 'package:fliin_ui/components/surface/fl_surface.dart';
import 'package:fliin_ui/core/fliin.dart';
import 'package:fliin_ui/icons/fl_icons.dart';

/// The container of a [FlAccordion].
enum FlAccordionVariant {
  /// No background: the sections and their rules on the page.
  plain,

  /// On a shadowed surface with continuous corners, wider margins.
  surface,
}

/// How many sections a [FlAccordion] opens at once.
enum FlAccordionSelection {
  /// Only one: opening one closes the other.
  single,

  /// As many as wanted.
  multiple,
}

/// A section of a [FlAccordion] (`Accordion.Item`): a pressable header and
/// content that unfolds.
final class FlAccordionItem<T> {
  /// The section [value], titled [title].
  const FlAccordionItem({
    required this.value,
    required this.title,
    required this.content,
    this.titleSpans,
    this.leading,
    this.indicator,
    this.enabled = true,
    this.subtitle,
    this.expandedIndicator,
  });

  /// The section's identifier among the open ones.
  final T value;

  /// The header's title, in `textBase` and `onSurface`, and its spoken name.
  final String title;

  /// The title in styled pieces (a word highlighted by a search), in the
  /// style of [title]; when null, [title] alone. Their text must be [title],
  /// so that the screen shows what is read.
  final List<InlineSpan>? titleSpans;

  /// The unfolded content, in muted `textBase` by default.
  final Widget content;

  /// An icon before the title, 12 away from it, in muted `onSurface` at
  /// `size.indicatorIcon` following the text up to `size.iconMaxScale`.
  final Widget? leading;

  /// An indicator in place of the rotating chevron (`Accordion.Indicator`
  /// and its children), with the icon theme of [leading]; it does not
  /// rotate.
  final Widget? indicator;

  /// False: the header is no longer pressable and fades to `disabledOpacity`.
  final bool enabled;

  /// A line under the title, in muted `textSm`: what the section holds.
  final String? subtitle;

  /// The indicator shown instead of [indicator] while the section is open,
  /// cross-faded over `short3`: a minus after a plus. It needs an
  /// [indicator]; when null, [indicator] stays.
  final Widget? expandedIndicator;
}

/// An accordion: sections whose header folds and
/// unfolds the content. The height follows the `motion.spring.layout`
/// spring, the content fades in and out over `short4` while it settles
/// `space.sm` down into the opening, and the chevron
/// turns half a turn on the `indicator` spring. Under reduced motion, the
/// height jumps and the content fades over `short3`.
///
/// The open sections are [expanded] when the app holds them, otherwise
/// [initialExpanded] then those the user opens; [onExpandedChanged]
/// receives each new set.
class FlAccordion<T> extends StatefulWidget {
  /// An accordion of [items], one section open at a time.
  const FlAccordion({
    super.key,
    required this.items,
    this.variant = FlAccordionVariant.plain,
    this.selection = FlAccordionSelection.single,
    this.collapsible = true,
    this.expanded,
    this.initialExpanded = const {},
    this.onExpandedChanged,
    this.enabled = true,
    this.separators = true,
  });

  /// The sections, top to bottom.
  final List<FlAccordionItem<T>> items;

  /// The container.
  final FlAccordionVariant variant;

  /// One section open at a time, or several.
  final FlAccordionSelection selection;

  /// False: an open section does not close through its header
  /// (`isCollapsible`).
  final bool collapsible;

  /// The open sections, held by the app; when null, the accordion holds them.
  final Set<T>? expanded;

  /// The sections open at creation, when [expanded] is null.
  final Set<T> initialExpanded;

  /// Called with the open sections after a press.
  final ValueChanged<Set<T>>? onExpandedChanged;

  /// False: no section is pressable (`isDisabled`).
  final bool enabled;

  /// False: no rule between the sections (`hideSeparator`).
  final bool separators;

  @override
  State<FlAccordion<T>> createState() => _FlAccordionState<T>();
}

class _FlAccordionState<T> extends State<FlAccordion<T>> {
  late Set<T> _expanded = widget.expanded ?? widget.initialExpanded;

  Set<T> get _current => widget.expanded ?? _expanded;

  void _toggle(T value) {
    final current = _current;
    final open = current.contains(value);
    if (open && !widget.collapsible) return;
    final next = switch (widget.selection) {
      FlAccordionSelection.single => open ? <T>{} : {value},
      FlAccordionSelection.multiple => open ? ({...current}..remove(value)) : {...current, value},
    };
    if (widget.expanded == null) setState(() => _expanded = next);
    widget.onExpandedChanged?.call(next);
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final surface = widget.variant == FlAccordionVariant.surface;
    final xs = theme.space.xs;
    final current = _current;
    final column = Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        for (final (index, item) in widget.items.indexed) ...[
          if (widget.separators && index > 0)
            Padding(
              // The surface's 12 of padding.
              padding: EdgeInsetsDirectional.symmetric(horizontal: surface ? xs * 3 : 0),
              child: const FlSeparator(),
            ),
          _Section<T>(
            key: ValueKey(item.value),
            item: item,
            expanded: current.contains(item.value),
            enabled: widget.enabled && item.enabled,
            // The canvas's `.acc-head`: 4 in, 16 on the surface (its 12 of
            // padding, then 4).
            inset: surface ? xs * 4 : xs,
            onPressed: () => _toggle(item.value),
          ),
        ],
      ],
    );
    if (!surface) return column;
    // The 1-wide border in the background color insets the content by as
    // much, and its antialiased stroke thickens the surface's edge.
    return DecoratedBox(
      position: DecorationPosition.foreground,
      decoration: ShapeDecoration(
        shape: FlContinuousBorder(
          radius: theme.shape.radiusCard,
          side: BorderSide(color: theme.colors.surfaceRaised, width: theme.size.border),
        ),
      ),
      child: FlSurface(padding: EdgeInsets.all(theme.size.border), child: column),
    );
  }
}

class _Section<T> extends StatefulWidget {
  const _Section({
    super.key,
    required this.item,
    required this.expanded,
    required this.enabled,
    required this.inset,
    required this.onPressed,
  });

  final FlAccordionItem<T> item;
  final bool expanded;
  final bool enabled;
  final double inset;
  final VoidCallback onPressed;

  @override
  State<_Section<T>> createState() => _SectionState<T>();
}

class _SectionState<T> extends State<_Section<T>> with TickerProviderStateMixin {
  late final AnimationController _height = AnimationController.unbounded(vsync: this, value: widget.expanded ? 1 : 0);
  late final AnimationController _turn = AnimationController.unbounded(vsync: this, value: widget.expanded ? 1 : 0);
  late final AnimationController _fade = AnimationController(vsync: this, value: widget.expanded ? 1 : 0);

  @override
  void didUpdateWidget(_Section<T> old) {
    super.didUpdateWidget(old);
    if (old.expanded == widget.expanded) return;
    final motion = FlMotion.of(context);
    final tokens = motion.tokens;
    final target = widget.expanded ? 1.0 : 0.0;
    if (motion.reduced) {
      _height.value = target;
      _turn.value = target;
      _fade.duration = tokens.short3;
    } else {
      _height.driveTo(target, FlSpringMotion(tokens.layout));
      _turn.driveTo(target, FlSpringMotion(tokens.indicator));
      _fade.duration = tokens.short4;
    }
    widget.expanded ? _fade.forward() : _fade.reverse();
  }

  @override
  void dispose() {
    _height.dispose();
    _turn.dispose();
    _fade.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final scale = theme.typography;
    final family = DefaultTextStyle.of(context).style.fontFamily;
    final item = widget.item;
    final xs = theme.space.xs;
    final leading = item.leading;
    final spans = item.titleSpans;
    assert(
      spans == null || TextSpan(children: spans).toPlainText() == item.title,
      'the text of titleSpans must be title',
    );
    final metrics = scale.style(FlTextRole.textBase);
    final subtitle = item.subtitle;
    final title = Text.rich(
      TextSpan(text: spans == null ? item.title : null, children: spans),
      style: scale.resolve(FlTextRole.textBase).copyWith(color: colors.onSurface),
      strutStyle: scale.strut(FlTextRole.textBase, inheritedFamily: family),
    );
    final indicator = switch ((item.indicator, item.expandedIndicator)) {
      (final closed?, final open?) => AnimatedCrossFade(
        firstChild: closed,
        secondChild: open,
        crossFadeState: widget.expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
        duration: FlMotion.of(context).reduced ? Duration.zero : theme.motion.short3,
      ),
      (final closed, _) => closed,
    };
    final icons = IconThemeData(
      color: colors.onSurfaceMuted,
      size: MediaQuery.textScalerOf(context)
          .clamp(maxScaleFactor: theme.size.iconMaxScale)
          .scale(theme.size.indicatorIcon),
    );
    final trigger = FlPressable(
      onPressed: widget.enabled ? widget.onPressed : null,
      borderRadius: BorderRadius.zero,
      scaleOnPress: false,
      // The trigger tints under the pointer or the finger, a hair of the
      // text color over the whole header.
      overlay: FlPressOverlay.highlight,
      focusRing: FlFocusRingPlacement.inside,
      expanded: widget.expanded,
      builder: (context, states) => Semantics(
        header: true,
        child: Opacity(
          opacity: widget.enabled ? 1 : theme.size.disabledOpacity,
          // At least the platform's target: the surface clips what
          // overflows, and the target of a shorter header would be cut off.
          // py-4 and gap-4; the title and its icon at gap-3.
          child: Container(
            constraints: BoxConstraints(minHeight: theme.hitTarget),
            alignment: AlignmentDirectional.centerStart,
            padding: EdgeInsetsDirectional.symmetric(horizontal: widget.inset, vertical: xs * 4),
            child: IconTheme.merge(
              data: icons,
              child: Row(
                spacing: xs * 4,
                children: [
                  Expanded(
                    child: Row(
                      spacing: xs * 3,
                      children: [
                        ?leading,
                        Expanded(
                          child: subtitle == null
                              ? title
                              : Column(
                                  mainAxisSize: MainAxisSize.min,
                                  crossAxisAlignment: CrossAxisAlignment.stretch,
                                  children: [
                                    title,
                                    Text(
                                      subtitle,
                                      style: scale.resolve(FlTextRole.textSm).copyWith(color: colors.onSurfaceMuted),
                                      strutStyle: scale.strut(FlTextRole.textSm, inheritedFamily: family),
                                    ),
                                  ],
                                ),
                        ),
                      ],
                    ),
                  ),
                  indicator ??
                      AnimatedBuilder(
                        animation: _turn,
                        builder: (context, child) => Transform.rotate(angle: -math.pi * _turn.value, child: child),
                        child: FlIcon(FlIcons.chevronDown),
                      ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
    final content = Padding(
      // pb-4.
      padding: EdgeInsetsDirectional.only(start: widget.inset, end: widget.inset, bottom: xs * 4),
      child: DefaultTextStyle.merge(
        style: scale
            .resolve(FlTextRole.textBase)
            .copyWith(
              color: colors.onSurfaceMuted,
              height: metrics.lineHeight / metrics.size,
              leadingDistribution: TextLeadingDistribution.even,
            ),
        child: item.content,
      ),
    );
    final curve = theme.motion.easeOut;
    final reduced = FlMotion.of(context).reduced;
    final opacity = CurvedAnimation(parent: _fade, curve: curve, reverseCurve: theme.motion.easeIn.flipped);
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        trigger,
        AnimatedBuilder(
          animation: Listenable.merge([_height, _fade]),
          builder: (context, child) {
            if (!widget.expanded && _height.value <= 0 && _fade.value <= 0) {
              return const SizedBox.shrink();
            }
            return ClipRect(
              child: Align(
                alignment: AlignmentDirectional.topStart,
                heightFactor: math.max(0, _height.value),
                // The content settles down into the opening as it fades in,
                // and lifts as it fades out; it only fades under reduced
                // motion.
                child: Transform.translate(
                  offset: Offset(0, reduced ? 0 : -(1 - opacity.value) * theme.space.sm),
                  child: FadeTransition(opacity: opacity, child: child),
                ),
              ),
            );
          },
          child: content,
        ),
      ],
    );
  }
}

Arrows to move, Enter to open, Escape to close.