Fliin
On this page

Components / data-display

Text

Gives the headings, paragraphs and inline code of the type scale, in the regular or muted color.

16 of 16 variants pass
Text, example state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add textAlso installs: core

Examples

Example

Headings, a body paragraph and a muted caption: type picks the step of the type scale, tone the emphasis.

Text, example state
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 TextExample extends StatelessWidget {
  const TextExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      spacing: FlScope.of(context).space.md,
      children: const [
        FlText('Welcome', type: FlTextType.h1),
        FlText('Getting Started', type: FlTextType.h3),
        FlText('This is a body paragraph rendered with the text component.'),
        FlText('Smaller supporting text for captions or footnotes.', type: FlTextType.bodySm, tone: FlTextTone.muted),
      ],
    );
  }
}

Types

The six heading steps, from h1 to h6, set by the theme’s type scale.

Text, types state
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 TextTypesExample extends StatelessWidget {
  const TextTypesExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      spacing: FlScope.of(context).space.md,
      children: [
        for (final type in const [
          FlTextType.h1,
          FlTextType.h2,
          FlTextType.h3,
          FlTextType.h4,
          FlTextType.h5,
          FlTextType.h6,
        ])
          FlText('Heading ${type.name.substring(1)}', type: type),
      ],
    );
  }
}

Rich

spans styles words inside one paragraph, a date in bold, a place in the accent, while the paragraph keeps its type and line height; data stays the plain text the screen reader reads.

Text, rich state
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 TextRichExample extends StatelessWidget {
  const TextRichExample({super.key});

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final bold = TextStyle(fontWeight: theme.tokens.typography.weight.semibold);
    final accent = TextStyle(color: theme.colors.accent);
    return FlText(
      'Your order ships on Friday. Follow it in Orders.',
      spans: [
        const TextSpan(text: 'Your order ships on '),
        TextSpan(text: 'Friday', style: bold),
        const TextSpan(text: '. Follow it in '),
        TextSpan(text: 'Orders', style: accent),
        const TextSpan(text: '.'),
      ],
    );
  }
}

Custom styles

An announcement: an eyebrow in the accent through color, a heading, and a muted paragraph, on a secondary surface.

Text, custom-styles state
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 TextCustomStylesExample extends StatelessWidget {
  const TextCustomStylesExample({super.key});

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    return FlSurface(
      variant: FlSurfaceVariant.secondary,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        spacing: theme.space.sm,
        children: [
          FlText(
            'CHANGELOG',
            type: FlTextType.bodyXs,
            weight: theme.tokens.typography.weight.medium,
            color: theme.colors.accent,
          ),
          const FlText('Faster search results', type: FlTextType.h4),
          const FlText(
            'Queries now return in under 200 ms thanks to an improved index.',
            type: FlTextType.bodySm,
            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.sm,
      children: [
        FlText('Settings', type: FlTextType.h2),
        FlText('Privacy', type: FlTextType.h5),
        FlText('Your data stays on this device.'),
        FlText(
          'Last synced at 9:41',
          type: FlTextType.bodySm,
          tone: FlTextTone.muted,
        ),
        const FlText('flutter pub get', type: FlTextType.code),
      ],
    ),
  ),
);

API

FlTextType: enum {h1, h2, h3, h4, h5, h6, body, bodySm, bodyXs, code}
FlTextType.h1: static final FlTextType
FlTextType.h2: static final FlTextType
FlTextType.h3: static final FlTextType
FlTextType.h4: static final FlTextType
FlTextType.h5: static final FlTextType
FlTextType.h6: static final FlTextType
FlTextType.body: static final FlTextType
FlTextType.bodySm: static final FlTextType
FlTextType.bodyXs: static final FlTextType
FlTextType.code: static final FlTextType
FlTextType.new: const new(FlTextRole role)
FlTextType.role: get FlTextRole
FlTextType.heading: get bool
FlTextTone: enum {normal, muted}
FlTextTone.normal: static final FlTextTone
FlTextTone.muted: static final FlTextTone
FlText: class extends StatelessWidget
FlText.new: const new(String data, {super.key, FlTextType type, FlTextTone tone, FontWeight? weight, TextAlign textAlign, int? maxLines, Color? color, List<InlineSpan>? spans})
FlText.data: get String
FlText.type: get FlTextType
FlText.tone: get FlTextTone
FlText.weight: get FontWeight?
FlText.textAlign: get TextAlign
FlText.maxLines: get int?
FlText.color: get Color?
FlText.spans: get List<InlineSpan>?
FlText.build: Widget(BuildContext context)

Reference renders

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

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

Use it to

  • show a page heading
  • add a paragraph of body text
  • show muted secondary text
  • present a command as inline code
  • pick a heading size from h1 to h6
Not for
  • text typed by the user: use a text field (text-field)

Source

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

text/fl_text.dart

// Modified from the Text (Typography) of HeroUI Native 1.0.10, Copyright 2025
// NextUI Inc., licensed under Apache 2.0:
// https://www.apache.org/licenses/LICENSE-2.0
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/core/fliin.dart';

/// The type of a [FlText].
enum FlTextType {
  /// h1 heading: 36 on 40, semibold.
  h1(FlTextRole.heading1),

  /// h2 heading: 30 on 36.
  h2(FlTextRole.heading2),

  /// h3 heading: 24 on 32.
  h3(FlTextRole.heading3),

  /// h4 heading: 20 on 28.
  h4(FlTextRole.heading4),

  /// h5 heading: 18 on 28.
  h5(FlTextRole.heading5),

  /// h6 heading: 16 on 24.
  h6(FlTextRole.heading6),

  /// Body text, the canvas's `t-base`: 16 on 24.
  body(FlTextRole.textBase),

  /// Small text, `t-sm`: 14 on 20.
  bodySm(FlTextRole.textSm),

  /// Extra-small text, `t-xs`: 12 on 16.
  bodyXs(FlTextRole.textXs),

  /// Inline code, in a monospace font on a `control` background.
  code(FlTextRole.code);

  const FlTextType(this.role);

  /// The role in the type scale.
  final FlTextRole role;

  /// True for headings, announced as such to screen readers.
  bool get heading => index <= h6.index;
}

/// The colour of a [FlText].
enum FlTextTone {
  /// `onSurface`, body text.
  normal,

  /// `onSurfaceMuted`, secondary text.
  muted,
}

/// A text from the type scale: headings, paragraphs and code, in the regular
/// or muted colour. Headings are announced as headers; the text stays
/// selectable under a `SelectionArea`.
class FlText extends StatelessWidget {
  /// [data] in the type [type], `body` by default.
  const FlText(
    this.data, {
    super.key,
    this.type = FlTextType.body,
    this.tone = FlTextTone.normal,
    this.weight,
    this.textAlign = TextAlign.start,
    this.maxLines,
    this.color,
    this.spans,
  });

  /// The text.
  final String data;

  /// Size, line height, weight and font.
  final FlTextType type;

  /// The colour.
  final FlTextTone tone;

  /// A weight instead of that of [type], from `typography.weight`.
  final FontWeight? weight;

  /// The alignment, at the start of the line by default.
  final TextAlign textAlign;

  /// The maximum number of lines, cut with an ellipsis; when null, no limit.
  final int? maxLines;

  /// A color instead of that of [tone]: `accent` for an eyebrow above a
  /// heading, `danger` for a warning.
  final Color? color;

  /// [data] in styled pieces (a word in bold, a link, inline code), each in
  /// the style of [type] unless it sets its own. Their text must be [data],
  /// so that the screen shows what is read.
  final List<InlineSpan>? spans;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final scale = theme.typography;
    final metrics = scale.style(type.role);
    final style = scale
        .resolve(type.role)
        .copyWith(
          color: color ?? (tone == FlTextTone.muted ? colors.onSurfaceMuted : colors.onSurface),
          fontWeight: weight,
        );
    final spans = this.spans;
    assert(spans == null || TextSpan(children: spans).toPlainText() == data, 'the text of spans must be data');
    final system = scale.familyOf(type.role) == null;
    final scaler = MediaQuery.textScalerOf(context);
    // Core Text removes the excess of a line height smaller than the font's
    // at the top of the line, where an even line height splits it: the text
    // moves up by half of that excess.
    var excess = 0.0;
    if (system) {
      final painter = TextPainter(
        text: TextSpan(style: DefaultTextStyle.of(context).style.merge(style)),
        textDirection: TextDirection.ltr,
        textScaler: scaler,
      )..layout();
      excess = painter.preferredLineHeight - scaler.scale(metrics.lineHeight);
      painter.dispose();
    }
    // The system font fits in the role's line height, even when smaller
    // than its own (h1): it is forced, as React Native does. A named font keeps
    // the line height as a minimum, so as not to overflow.
    final textStyle = system
        ? style.copyWith(height: metrics.lineHeight / metrics.size, leadingDistribution: TextLeadingDistribution.even)
        : style;
    final strut = theme.typography.strut(type.role, inheritedFamily: DefaultTextStyle.of(context).style.fontFamily);
    final overflow = maxLines == null ? null : TextOverflow.ellipsis;
    Widget text = spans == null
        ? Text(data, textAlign: textAlign, maxLines: maxLines, overflow: overflow, style: textStyle, strutStyle: strut)
        : Text.rich(
            TextSpan(children: spans),
            textAlign: textAlign,
            maxLines: maxLines,
            overflow: overflow,
            style: textStyle,
            strutStyle: strut,
          );
    if (excess > 0) {
      text = Transform.translate(offset: Offset(0, -excess / 2), child: text);
    }
    if (type.heading) return Semantics(header: true, child: text);
    if (type != FlTextType.code) return text;
    return Align(
      alignment: AlignmentDirectional.topStart,
      widthFactor: 1,
      heightFactor: 1,
      child: DecoratedBox(
        decoration: ShapeDecoration(
          color: colors.control,
          shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(theme.shape.radiusMd)),
        ),
        child: Padding(
          padding: EdgeInsetsDirectional.symmetric(
            horizontal: theme.size.codePaddingX,
            vertical: theme.size.codePaddingY,
          ),
          child: text,
        ),
      ),
    );
  }
}

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