Fliin
On this page

Components / data-display

Keyboard key

Shows a key or a shortcut to type on a keyboard, such as ⌘ K: Apple symbols on iOS and macOS, Ctrl or Alt elsewhere, on a key cap or bare.

16 of 16 variants pass
Keyboard key, shortcut state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add kbdAlso installs: core

Examples

Shortcut

A shortcut: modifier keys from FlKbdKey, then the letter as text. Give semanticLabel when the glyphs alone would read poorly.

Keyboard key, shortcut state
import 'package:your_app/ui/fliin/components/kbd/fl_kbd.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Wrap(
      alignment: WrapAlignment.center,
      crossAxisAlignment: WrapCrossAlignment.center,
      spacing: space.sm,
      runSpacing: space.sm,
      children: [
        const FlKbd(keys: [FlKbdKey.command], text: 'K', semanticLabel: 'Command K'),
        const FlKbd(keys: [FlKbdKey.command, FlKbdKey.shift], text: 'Z'),
        const FlKbd(keys: [FlKbdKey.option, FlKbdKey.control], text: 'F'),
      ],
    );
  }
}

Keys

The named keys: enter, escape, tab, delete and the four arrows, each drawn with its symbol.

Keyboard key, keys state
import 'package:your_app/ui/fliin/components/kbd/fl_kbd.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Wrap(
      alignment: WrapAlignment.center,
      crossAxisAlignment: WrapCrossAlignment.center,
      spacing: space.sm,
      runSpacing: space.sm,
      children: [
        const FlKbd(keys: [FlKbdKey.enter]),
        const FlKbd(keys: [FlKbdKey.escape]),
        const FlKbd(keys: [FlKbdKey.tab]),
        const FlKbd(keys: [FlKbdKey.delete]),
        const FlKbd(keys: [FlKbdKey.up]),
        const FlKbd(keys: [FlKbdKey.down]),
        const FlKbd(keys: [FlKbdKey.left]),
        const FlKbd(keys: [FlKbdKey.right]),
      ],
    );
  }
}

Inline

Inside a sentence, the light variant sits on the text without weighing on it.

Keyboard key, inline state
import 'package:your_app/ui/fliin/components/kbd/fl_kbd.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 KbdInlineExample extends StatelessWidget {
  const KbdInlineExample({super.key});

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Wrap(
      alignment: WrapAlignment.center,
      crossAxisAlignment: WrapCrossAlignment.center,
      spacing: space.sm,
      runSpacing: space.sm,
      children: [
        const FlText('Press'),
        const FlKbd(keys: [FlKbdKey.command], text: 'K', variant: FlKbdVariant.light),
        const FlText('to search'),
      ],
    );
  }
}

Variants

Each shortcut twice: the default filled cap, then light, without a background, for a menu row or a dense list.

Keyboard key, variants state
import 'package:your_app/ui/fliin/components/kbd/fl_kbd.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 KbdVariantsExample extends StatelessWidget {
  const KbdVariantsExample({super.key});

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    Widget row(String action, List<FlKbdKey> keys, String letter) => Wrap(
      crossAxisAlignment: WrapCrossAlignment.center,
      spacing: space.sm,
      runSpacing: space.xs,
      children: [
        ConstrainedBox(
          constraints: BoxConstraints(minWidth: space.xxl * 2),
          child: FlText(action, type: FlTextType.bodySm),
        ),
        FlKbd(keys: keys, text: letter),
        FlKbd(keys: keys, text: letter, variant: FlKbdVariant.light),
      ],
    );
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      spacing: space.md,
      children: [
        row('Copy', const [FlKbdKey.command], 'C'),
        row('Paste', const [FlKbdKey.command], 'V'),
        row('Cut', const [FlKbdKey.command], 'X'),
        row('Undo', const [FlKbdKey.command], 'Z'),
        row('Redo', const [FlKbdKey.command, FlKbdKey.shift], 'Z'),
      ],
    );
  }
}

Navigation

The arrows, drawn on every platform and never mirrored, then the page keys: words outside Apple platforms, ↖ and ↘ for Home and End on iOS and macOS.

Keyboard key, navigation state
import 'package:your_app/ui/fliin/components/kbd/fl_kbd.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 KbdNavigationExample extends StatelessWidget {
  const KbdNavigationExample({super.key});

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    Widget row(String title, List<FlKbdKey> keys) => Wrap(
      crossAxisAlignment: WrapCrossAlignment.center,
      spacing: space.sm,
      runSpacing: space.sm,
      children: [
        FlText(title, type: FlTextType.bodySm, tone: FlTextTone.muted),
        for (final key in keys) FlKbd(keys: [key]),
      ],
    );
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      spacing: space.md,
      children: [
        row('Arrow keys', const [FlKbdKey.up, FlKbdKey.down, FlKbdKey.left, FlKbdKey.right]),
        row('Page navigation', const [FlKbdKey.pageUp, FlKbdKey.pageDown, FlKbdKey.home, FlKbdKey.end]),
      ],
    );
  }
}

Special

Enter, Escape, Tab and Space inside sentences; Shift and Tab held together share one cap.

Keyboard key, special state
import 'package:your_app/ui/fliin/components/kbd/fl_kbd.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 KbdSpecialExample extends StatelessWidget {
  const KbdSpecialExample({super.key});

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    Widget sentence(List<Widget> parts) =>
        Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: space.xs, runSpacing: space.xs, children: parts);
    const sm = FlTextType.bodySm;
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      spacing: space.md,
      children: [
        sentence(const [
          FlText('Press', type: sm),
          FlKbd(keys: [FlKbdKey.enter]),
          FlText('to confirm or', type: sm),
          FlKbd(keys: [FlKbdKey.escape]),
          FlText('to cancel.', type: sm),
        ]),
        sentence(const [
          FlText('Use', type: sm),
          FlKbd(keys: [FlKbdKey.tab]),
          FlText('to move on and', type: sm),
          FlKbd(keys: [FlKbdKey.shift, FlKbdKey.tab]),
          FlText('to go back.', type: sm),
        ]),
        sentence(const [
          FlText('Hold', type: sm),
          FlKbd(keys: [FlKbdKey.space]),
          FlText('to pan the canvas.', type: sm),
        ]),
      ],
    );
  }
}

Instructional

A list of shortcuts in a surface, each action at the start and its keys at the end, like the keyboard help of an iPad app.

Keyboard key, instructional state
import 'package:your_app/ui/fliin/components/kbd/fl_kbd.dart';
import 'package:your_app/ui/fliin/components/surface/fl_surface.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 KbdInstructionalExample extends StatelessWidget {
  const KbdInstructionalExample({super.key});

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    Widget shortcut(String action, String letter) => Row(
      spacing: theme.space.sm,
      children: [
        Expanded(child: FlText(action, type: FlTextType.bodySm)),
        FlKbd(keys: const [FlKbdKey.command], text: letter),
      ],
    );
    return FlSurface(
      variant: FlSurfaceVariant.secondary,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        spacing: theme.space.sm,
        children: [
          FlText('Quick actions', type: FlTextType.bodySm, weight: theme.tokens.typography.weight.semibold),
          shortcut('Open search', 'K'),
          shortcut('Toggle sidebar', 'B'),
          shortcut('New file', 'N'),
          shortcut('Quick save', 'S'),
        ],
      ),
    );
  }
}

Custom colors

color and foregroundColor repaint the cap from your theme's colors, for a key that matches a selected row or a brand.

Keyboard key, custom-colors state
import 'package:your_app/ui/fliin/components/kbd/fl_kbd.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    return Wrap(
      alignment: WrapAlignment.center,
      crossAxisAlignment: WrapCrossAlignment.center,
      spacing: theme.space.sm,
      runSpacing: theme.space.sm,
      children: [
        FlKbd(
          keys: const [FlKbdKey.command],
          text: 'K',
          color: theme.colors.selected,
          foregroundColor: theme.colors.onSelected,
        ),
        FlKbd(
          keys: const [FlKbdKey.shift],
          text: 'P',
          color: theme.colors.surfaceSecondary,
          foregroundColor: theme.colors.onSurface,
        ),
      ],
    );
  }
}

Usage

The code the reference images render:

final example = ColoredBox(
  color: FlScope.of(context).colors.surface,
  child: SingleChildScrollView(
    padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.sm,
      children: [
        Wrap(
          spacing: FlScope.of(context).space.sm,
          runSpacing: FlScope.of(context).space.sm,
          crossAxisAlignment: WrapCrossAlignment.center,
          children: [
            FlKbd(
              keys: const [FlKbdKey.command],
              text: 'K',
              semanticLabel: 'Command K',
            ),
            FlKbd(keys: const [FlKbdKey.command, FlKbdKey.shift], text: 'Z'),
            FlKbd(keys: const [FlKbdKey.option, FlKbdKey.control], text: 'F'),
            FlKbd(keys: const [FlKbdKey.enter]),
            FlKbd(keys: const [FlKbdKey.escape]),
            FlKbd(keys: const [FlKbdKey.tab]),
          ],
        ),
        Wrap(
          spacing: FlScope.of(context).space.sm,
          runSpacing: FlScope.of(context).space.sm,
          crossAxisAlignment: WrapCrossAlignment.center,
          children: [
            FlKbd(keys: const [FlKbdKey.up]),
            FlKbd(keys: const [FlKbdKey.down]),
            FlKbd(keys: const [FlKbdKey.left]),
            FlKbd(keys: const [FlKbdKey.right]),
            FlKbd(keys: const [FlKbdKey.space]),
            FlKbd(keys: const [FlKbdKey.delete]),
          ],
        ),
        Wrap(
          spacing: FlScope.of(context).space.sm,
          runSpacing: FlScope.of(context).space.sm,
          crossAxisAlignment: WrapCrossAlignment.center,
          children: [
            Text(
              'Copy',
              style: FlScope.of(context).typography
                  .resolve(FlTextRole.textSm)
                  .copyWith(color: FlScope.of(context).colors.onSurface),
            ),
            FlKbd(
              keys: const [FlKbdKey.command],
              text: 'C',
              variant: FlKbdVariant.light,
            ),
            Text(
              'Paste',
              style: FlScope.of(context).typography
                  .resolve(FlTextRole.textSm)
                  .copyWith(color: FlScope.of(context).colors.onSurface),
            ),
            FlKbd(
              keys: const [FlKbdKey.command],
              text: 'V',
              variant: FlKbdVariant.light,
            ),
          ],
        ),
      ],
    ),
  ),
);

API

FlKbdKey: enum {command, shift, control, option, enter, delete, escape, tab, capsLock, up, right, down, left, pageUp, pageDown, home, end, space, fn}
FlKbdKey.command: static final FlKbdKey
FlKbdKey.shift: static final FlKbdKey
FlKbdKey.control: static final FlKbdKey
FlKbdKey.option: static final FlKbdKey
FlKbdKey.enter: static final FlKbdKey
FlKbdKey.delete: static final FlKbdKey
FlKbdKey.escape: static final FlKbdKey
FlKbdKey.tab: static final FlKbdKey
FlKbdKey.capsLock: static final FlKbdKey
FlKbdKey.up: static final FlKbdKey
FlKbdKey.right: static final FlKbdKey
FlKbdKey.down: static final FlKbdKey
FlKbdKey.left: static final FlKbdKey
FlKbdKey.pageUp: static final FlKbdKey
FlKbdKey.pageDown: static final FlKbdKey
FlKbdKey.home: static final FlKbdKey
FlKbdKey.end: static final FlKbdKey
FlKbdKey.space: static final FlKbdKey
FlKbdKey.fn: static final FlKbdKey
FlKbdKey.new: const new(String word, {String? apple})
FlKbdKey.word: get String
FlKbdKey.apple: get String?
FlKbdVariant: enum {filled, light}
FlKbdVariant.filled: static final FlKbdVariant
FlKbdVariant.light: static final FlKbdVariant
FlKbd: class extends StatelessWidget
FlKbd.new: const new({super.key, List<FlKbdKey> keys, String? text, FlKbdVariant variant, String? semanticLabel, Color? color, Color? foregroundColor})
FlKbd.keys: get List<FlKbdKey>
FlKbd.text: get String?
FlKbd.variant: get FlKbdVariant
FlKbd.semanticLabel: get String?
FlKbd.color: get Color?
FlKbd.foregroundColor: get Color?
FlKbd.build: Widget(BuildContext context)

Reference renders

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

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

Use it to

  • show a keyboard shortcut next to a command
  • explain that Escape closes the panel
  • present Cmd K or Ctrl K in a help text
  • use arrow key caps in keyboard navigation hints
  • give the hotkeys of an iPad with a hardware keyboard
Not for
  • a status or category label: use a chip (chip)
  • a counter on an icon: use a badge (badge)
States
  • Shortcut
  • Keys
  • Inline

Source

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

kbd/fl_kbd.dart

// Modified from the Kbd of HeroUI React v3, 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/core/fliin.dart';

/// A special key of an [FlKbd]: Apple's symbol on iOS and macOS (⌘, ⇧, ⌥),
/// the word elsewhere (Ctrl, Shift, Alt), as each platform's keyboards
/// print it. The symbols and the arrows are drawn, not typeset: no font
/// has to carry them.
enum FlKbdKey {
  /// ⌘, or Ctrl outside Apple platforms.
  command('Ctrl'),

  /// ⇧, or Shift.
  shift('Shift'),

  /// ⌃, or Ctrl.
  control('Ctrl'),

  /// ⌥, or Alt.
  option('Alt'),

  /// ⏎, or Enter.
  enter('Enter'),

  /// ⌫, or Backspace.
  delete('Backspace'),

  /// Esc, written on every platform, as Apple's keyboards print it too.
  escape('Esc'),

  /// Tab, written on every platform.
  tab('Tab'),

  /// ⇪, or Caps Lock.
  capsLock('Caps Lock'),

  /// ↑, drawn on every platform.
  up('↑'),

  /// →, drawn on every platform, never mirrored: the key is the physical one.
  right('→'),

  /// ↓, drawn on every platform.
  down('↓'),

  /// ←, drawn on every platform, never mirrored.
  left('←'),

  /// Page Up, PgUp outside Apple platforms.
  pageUp('PgUp', apple: 'Page Up'),

  /// Page Down, PgDn outside Apple platforms.
  pageDown('PgDn', apple: 'Page Down'),

  /// ↖, or Home.
  home('Home'),

  /// ↘, or End.
  end('End'),

  /// Space, written on every platform.
  space('Space'),

  /// Fn, written on every platform.
  fn('Fn');

  const FlKbdKey(this.word, {this.apple});

  /// The key as written outside Apple platforms, and read by default.
  final String word;

  /// The word on iOS and macOS, when it differs and no symbol stands for it.
  final String? apple;
}

/// The background of an [FlKbd].
enum FlKbdVariant {
  /// A `control` key cap.
  filled,

  /// No background: a key inside a sentence or a menu row.
  light,
}

const _line = FlIconStroke(width: 1.4);

Path _polyline(List<(double, double)> points, {bool close = false}) {
  final path = Path()..moveTo(points.first.$1, points.first.$2);
  for (final (x, y) in points.skip(1)) {
    path.lineTo(x, y);
  }
  if (close) path.close();
  return path;
}

Path _arrow(double x0, double y0, double x1, double y1) {
  // The head's two strokes, a quarter turn apart, 4.5 back from the tip.
  final angle = math.atan2(y1 - y0, x1 - x0);
  const reach = 4.5;
  (double, double) wing(double turn) => (x1 - reach * math.cos(angle + turn), y1 - reach * math.sin(angle + turn));
  return _polyline([(x0, y0), (x1, y1)])
    ..addPath(_polyline([wing(math.pi / 4), (x1, y1), wing(-math.pi / 4)]), Offset.zero);
}

/// The drawn symbols, in a box of 16: Apple's modifiers and the arrows.
final Map<String, FlIconData> _glyphs = {
  'command': FlIconData(() {
    final path = Path();
    // Three quarters of a loop at each corner, the quarter facing the
    // middle left out, then the four sides of the square through them.
    for (final (cx, cy, start) in [
      (3.75, 3.75, math.pi / 2),
      (12.25, 3.75, math.pi),
      (12.25, 12.25, 3 * math.pi / 2),
      (3.75, 12.25, 0.0),
    ]) {
      path.addArc(Rect.fromCircle(center: Offset(cx, cy), radius: 2.25), start, 3 * math.pi / 2);
    }
    for (final at in [6.0, 10.0]) {
      path
        ..moveTo(3.75, at)
        ..lineTo(12.25, at)
        ..moveTo(at, 3.75)
        ..lineTo(at, 12.25);
    }
    return path;
  }(), stroke: _line),
  'shift': FlIconData(
    _polyline([(8, 2), (14, 8.5), (11, 8.5), (11, 14), (5, 14), (5, 8.5), (2, 8.5)], close: true),
    stroke: _line,
  ),
  'control': FlIconData(_polyline([(3, 9.5), (8, 4.5), (13, 9.5)]), stroke: _line),
  'option': FlIconData(
    _polyline([(1.5, 4), (5.5, 4), (10.5, 12), (14.5, 12)])
      ..moveTo(10, 4)
      ..lineTo(14.5, 4),
    stroke: _line,
  ),
  'enter': FlIconData(
    _polyline([(13.5, 3), (13.5, 9.5), (3, 9.5)])..addPath(_polyline([(6.5, 6), (3, 9.5), (6.5, 13)]), Offset.zero),
    stroke: _line,
  ),
  'delete': FlIconData(
    _polyline([(5.5, 3), (14.5, 3), (14.5, 13), (5.5, 13), (1.5, 8)], close: true)
      ..moveTo(8.25, 6)
      ..lineTo(11.75, 10)
      ..moveTo(11.75, 6)
      ..lineTo(8.25, 10),
    stroke: _line,
  ),
  'capsLock': FlIconData(
    _polyline([(8, 1.5), (14, 7.5), (11, 7.5), (11, 10.5), (5, 10.5), (5, 7.5), (2, 7.5)], close: true)
      ..addRect(const Rect.fromLTRB(5, 13, 11, 14.5)),
    stroke: _line,
  ),
  'home': FlIconData(_arrow(12.5, 12.5, 3.5, 3.5), stroke: _line),
  'end': FlIconData(_arrow(3.5, 3.5, 12.5, 12.5), stroke: _line),
  'right': FlIconData(_arrow(2, 8, 14, 8), stroke: _line),
};

/// A key or a shortcut to type on a keyboard: the special
/// [keys], then [text], on a `control` cap `space.xl` high with
/// `radiusLg` corners and `space.sm` of padding, in `textSm` medium
/// `onSurfaceMuted`. It shows, it does not act: on a phone, it tells the
/// shortcut of a hardware keyboard (iPad, Chromebook, desktop).
///
/// The screen reader reads [semanticLabel], or else the keys' words and
/// [text]; give it the words of the app's language ("Commande K").
///
/// [color] and [foregroundColor] repaint the cap and its glyphs, for a key
/// that matches a highlighted row or a brand color.
///
/// ```dart
/// FlKbd(keys: [FlKbdKey.command], text: 'K', semanticLabel: 'Command K')
/// ```
class FlKbd extends StatelessWidget {
  /// A key cap showing [keys], then [text]; at least one of the two.
  const FlKbd({
    super.key,
    this.keys = const [],
    this.text,
    this.variant = FlKbdVariant.filled,
    this.semanticLabel,
    this.color,
    this.foregroundColor,
  });

  /// The special keys, in the order they are held.
  final List<FlKbdKey> keys;

  /// The key typed last, as written on it ("K", "Delete").
  final String? text;

  /// A `control` cap, or none; `filled` by default.
  final FlKbdVariant variant;

  /// What the screen reader reads instead ("Command K").
  final String? semanticLabel;

  /// The cap's fill, in place of [variant]'s: `control` for `filled`, none
  /// for `light`.
  final Color? color;

  /// The color of the symbols and the text; `onSurfaceMuted` by default.
  final Color? foregroundColor;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final apple = switch (theme.platform) {
      TargetPlatform.iOS || TargetPlatform.macOS => true,
      _ => false,
    };
    const role = FlTextRole.textSm;
    final style = theme.typography
        .resolve(role)
        .copyWith(
          color: foregroundColor ?? theme.colors.onSurfaceMuted,
          fontWeight: theme.tokens.typography.weight.medium,
        );
    final strut = theme.typography.strut(role, inheritedFamily: DefaultTextStyle.of(context).style.fontFamily);
    // As tall as the text's capitals and the symbols of the system font.
    final glyphSize = MediaQuery.textScalerOf(context)
        .clamp(maxScaleFactor: theme.size.iconMaxScale)
        .scale(theme.typography.style(role).size);

    Widget drawn(FlIconData glyph, {int turns = 0}) => Directionality(
      // The key's arrow points the same way in RTL.
      textDirection: TextDirection.ltr,
      child: RotatedBox(
        quarterTurns: turns,
        child: FlIcon(glyph, size: glyphSize, color: style.color),
      ),
    );

    Widget written(String word) => Text(word, style: style, strutStyle: strut);

    Widget glyph(FlKbdKey key) => switch (key) {
      FlKbdKey.up => drawn(_glyphs['right']!, turns: 3),
      FlKbdKey.right => drawn(_glyphs['right']!),
      FlKbdKey.down => drawn(_glyphs['right']!, turns: 1),
      FlKbdKey.left => drawn(_glyphs['right']!, turns: 2),
      _ when apple => switch (_glyphs[key.name]) {
        final symbol? => drawn(symbol),
        null => written(key.apple ?? key.word),
      },
      _ => written(key.word),
    };

    final text = this.text;
    final spoken = semanticLabel ?? [for (final key in keys) key.word, ?text].join(' ');
    // Half a gap between symbols; words take a full one.
    final words = !apple && keys.any((key) => key.index < FlKbdKey.up.index || key.index > FlKbdKey.left.index);
    final content = Row(
      mainAxisSize: MainAxisSize.min,
      spacing: words ? theme.space.xs : theme.space.xs / 2,
      children: [for (final key in keys) glyph(key), if (text != null) written(text)],
    );
    return Semantics(
      container: true,
      label: spoken,
      child: ExcludeSemantics(
        child: ConstrainedBox(
          constraints: BoxConstraints(minHeight: theme.space.xl, minWidth: theme.space.xl),
          child: DecoratedBox(
            decoration: ShapeDecoration(
              color: color ?? (variant == FlKbdVariant.filled ? theme.colors.control : null),
              shape: FlContinuousBorder(radius: theme.shape.radiusLg),
            ),
            child: Padding(
              padding: EdgeInsetsDirectional.symmetric(horizontal: theme.space.sm, vertical: theme.space.xs / 2),
              child: Center(widthFactor: 1, heightFactor: 1, child: content),
            ),
          ),
        ),
      ),
    );
  }
}

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