---
title: Typography
description: >-
  Fortal's Text, Heading, Code, Kbd, and Link widgets on one shared nine-step
  scale
keywords:
  - flutter
  - remix
  - fortal
  - typography
  - text
  - heading
  - code
  - kbd
  - link
  - scale
---
> ## Documentation Index
> Fetch the complete documentation index at: https://docs.page/btwld/remix/llms.txt
> Use this file to discover all available pages before exploring further.

Fortal ships five typography families — `FortalText`, `FortalHeading`,
`FortalCode`, `FortalKbd`, and `FortalLink`. They share one nine-step
size scale (`FortalTextSize`) and one weight enum (`FortalTextWeight`) instead
of declaring five parallel copies.

These families live only in the application-owned Fortal preset. There is no
`RemixText` or `RemixHeading`: base Remix stays theme-free, and a `TextStyler`
plus your own tokens is the equivalent there. The examples assume the preset
was initialized with `--prefix Fortal` as shown in the [Fortal overview](/fortal).

For visual reference, open the [light matrix](/assets/fortal-typography-light.png)
or [dark matrix](/assets/fortal-typography-dark.png). These Calçadão captures
show the current size and variant scales; they are not interactive previews.

## Shared type scale

| Step | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Size (px at 100%) | 12 | 14 | 16 | 18 | 20 | 24 | 28 | 36 | 60 |

All values follow theme scaling. Accordion and disclosure labels use the same
`text2` / `text3` tokens as the rest of the system.

## Choosing a widget

| Widget | Use it for | Semantics |
|--------|-----------|-----------|
| `FortalText` | Body copy, labels, values | None; one plain `Text` node |
| `FortalHeading` | Page, section, and card titles | `header` with an explicit `headingLevel` |
| `FortalCode` | Identifiers, snippets, tokens shown inline | None; Flutter has no code role |
| `FortalKbd` | One keyboard key or shortcut | `keyboardKey`, inert |
| `FortalLink` | Text that navigates | `link` **only** while enabled and given an `onPressed` |

`FortalText`, `FortalCode`, `FortalKbd`, and `FortalLink` keep nullable public
`size` fields, but an omitted size resolves the active scope's `text3` token
rather than the ambient `DefaultTextStyle`. `FortalText` also defaults to the
regular weight token and neutral `gray-12`; `FortalHeading` defaults to size 6,
bold, and neutral `gray-12`.

Code and Kbd have compact, token-anchored adjustments: an unsized soft Code resolves
`0.95 × 0.95 × text3`, and an unsized Kbd resolves `0.75 × text3`. Pass an
explicit `size:` to select another token size.

The outermost `FortalTheme` still establishes the root text run as a courtesy
`DefaultTextStyle` for bare Flutter `Text`. Under `WidgetsApp`, put the scope in `builder:` so that fallback reaches routes and
raw overlays; see [scope placement](/fortal#scope-placement). A nearer
`DefaultTextStyle` can replace the run for bare text, but never changes Fortal
typography metrics.

A nested `FortalTheme` re-scopes tokens without restating the courtesy
bare-`Text` run. Fortal typography re-resolves against the nested gray and
scaling tokens, while ordinary Flutter text keeps its nearest inherited style.

## Basic implementation

<CodeGroup title="Basic implementation" defaultLanguage="dart">
```dart
import 'package:flutter/material.dart';
import 'ui/ui.dart';

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

  @override
  Widget build(BuildContext context) {
    return const Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      spacing: 12,
      children: [
        FortalHeading('Release notes', size: FortalTextSize.size6),
        FortalText(
          'Every recipe resolves through the active Fortal scope.',
          size: FortalTextSize.size3,
        ),
        Row(
          spacing: 10,
          children: [
            FortalCode.soft('FortalTheme', size: FortalTextSize.size2),
            FortalKbd.soft(
              '⌘K',
              size: FortalTextSize.size2,
              semanticLabel: 'Command K',
            ),
          ],
        ),
      ],
    );
  }
}
```
</CodeGroup>

## Size, weight, and flow

Every family accepts the same scale and flow controls.

| Control | Type | Applies to |
|---------|------|------------|
| `size` | `FortalTextSize?` — `size1`–`size9` | all five |
| `weight` | `FortalTextWeight?` — `light`, `regular`, `medium`, `bold` | Text, Heading, Code, Link |
| `align` | `TextAlign?` | Text, Heading |
| `softWrap` | `bool` (default `true`) | Text, Heading, Code, Link |
| `truncate` | `bool` (default `false`) | Text, Heading, Code, Link |
| `accent` | `bool` (default `false`) | Text, Heading, Code |
| `highContrast` | `bool` (default `false`) | Text, Heading, Code, Link |

`truncate` deliberately wins over `softWrap`: it forces exactly one ellipsized
line, which is what a table cell or a row with a fixed-width neighbour needs.

`FortalText` and `FortalHeading` use neutral `gray-12` unless `accent: true`
takes the surrounding `FortalTheme` accent at `accent-a11`; adding
`highContrast: true` promotes it to `accent-12`. `highContrast` alone leaves
the neutral color unchanged.

`FortalKbd` is the exception: it pins its own regular weight, line box, and
`gray-12` foreground so a key cap never follows the surrounding copy.

## Variants

<CodeGroup title="Code and Kbd variants" defaultLanguage="dart">
```dart
import 'package:flutter/material.dart';
import 'ui/ui.dart';

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

  @override
  Widget build(BuildContext context) {
    return const Wrap(
      spacing: 12,
      runSpacing: 12,
      crossAxisAlignment: WrapCrossAlignment.center,
      children: [
        FortalCode.solid('solid'),
        FortalCode.soft('soft'),
        FortalCode.outline('outline'),
        FortalCode.ghost('ghost'),
        FortalKbd.soft('⌘K', semanticLabel: 'Command K'),
        FortalKbd.soft('Esc', semanticLabel: 'Escape'),
      ],
    );
  }
}
```
</CodeGroup>

`FortalCode.ghost` is transparent and inherits the ambient foreground unless you
pass `accent: true`; the other three variants always paint their own accent
roles. A foreground composed onto the recipe overrides that ambient fallback.

## Headings and semantic level

`headingLevel` drives the accessibility level only. Changing it never changes
the visual `size`. A page title
can be level 1 at size 6 while an empty-state title stays level 2 at size 3.

<CodeGroup title="Heading level and size" defaultLanguage="dart">
```dart
import 'package:flutter/material.dart';
import 'ui/ui.dart';

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

  @override
  Widget build(BuildContext context) {
    return const Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      spacing: 8,
      children: [
        FortalHeading('Overview', size: FortalTextSize.size6),
        FortalHeading(
          'Recent activity',
          headingLevel: 2,
          size: FortalTextSize.size4,
          weight: FortalTextWeight.medium,
        ),
      ],
    );
  }
}
```
</CodeGroup>

`FortalHeading` publishes exactly one `Semantics` node with `header: true` and
the requested level, and excludes the child so the label is not announced
twice. Pass `semanticLabel` when the announced text should differ from the
rendered text, or `excludeSemantics: true` to publish nothing at all.

## Actionable versus disabled links

A `FortalLink` without `onPressed` is **disabled**, the same as one with
`enabled: false` — a null callback disables a Flutter control. It keeps the
accent colour but gives up its focus stop, link role, and activation. Give it
`onPressed` and it becomes a real link: focusable, activatable with pointer and
Enter, and underlined according to `underline`.

A disabled link never underlines regardless of `underline`. For accent-coloured
text that was never meant to be followed, use `FortalText(accent: true)`.

Space does **not** activate a link. That is the Button role's key; a link takes
Enter, matching an anchor on the web.

| `underline` | Behaviour when actionable |
|-------------|---------------------------|
| `auto` (default) | Underlines on hover, or always at high contrast |
| `always` | Always underlined |
| `hover` | Underlines on hover only |
| `none` | Never underlined |

A focus-visible outline replaces the underline rather than stacking both, and
the outline only appears for keyboard focus — a touch or pointer focus keeps the
idle treatment.

<CodeGroup title="Links" defaultLanguage="dart">
```dart
import 'package:flutter/material.dart';
import 'ui/ui.dart';

class LinkExample extends StatelessWidget {
  const LinkExample({super.key, required this.onOpenDocs});

  final VoidCallback onOpenDocs;

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 16,
      children: [
        FortalLink(
          'Read the docs',
          underline: FortalLinkUnderline.always,
          linkUrl: Uri.parse('https://docs.page/btwld/remix/fortal'),
          semanticHint: 'Opens the Fortal documentation',
          onPressed: onOpenDocs,
        ),
        const FortalLink('Not available yet'),
      ],
    );
  }
}
```
</CodeGroup>

`linkUrl` is assistive metadata only and is **never launched**. Navigation stays
your responsibility inside `onPressed`, which keeps the owned recipe free of a
URL launcher dependency. Passing `linkUrl` without `onPressed` asserts, because
an announced destination with no activation is a broken promise to assistive
technology.

`enabled: false` keeps the callback and still refuses focus and activation, so
a link that is only temporarily unavailable can hold on to its destination.

## Customizing the recipes

Each widget calls a `fortal*Style` recipe you can use directly. `FortalText` and
`FortalHeading` return a `TextStyler`; `FortalCode` and `FortalKbd` return a
`BadgeStyler` and `FortalLink` returns a `LinkStyler`. All three take a
`BuildContext`, because their geometry is em-relative to the resolved font size.

<CodeGroup title="Recipe customization" defaultLanguage="dart">
```dart
import 'package:flutter/material.dart';
import 'package:remix/remix.dart';
import 'ui/ui.dart';

/// Body text on the Fortal scale with one project-specific neutral tone.
TextStyler mutedBody() => fortalTextStyle(
  size: FortalTextSize.size2,
  weight: FortalTextWeight.medium,
).color(FortalTokens.gray11());

/// The Fortal Code recipe with a wider inline gutter.
BadgeStyler roomyCode(BuildContext context) =>
    fortalCodeStyle(context, size: FortalTextSize.size2)
        .padding(.horizontal(10));
```
</CodeGroup>

Starting from the recipe keeps the scale, weights, and flow behaviour Fortal's
and layers only what your project actually adds. Reaching for a bare
`TextStyler` instead re-declares the whole type ramp locally.

## Composition notes

- Flutter paints the token line box without leading trim. `softWrap` and
  `truncate` control flow; use layout constraints to select responsive values.
- `FortalCode` and `FortalKbd` are standalone boxes, not inline text spans.
  Compose them in a `Wrap` or row alongside text.
- Link underline thickness follows the font's underline metrics. Keyboard
  focus uses the shared outside ring and suppresses the underline.
- Kbd has only the flat `soft` variant and is inert, with keyboard-key semantics.
- Scope a named accent or generated brand palette around a subtree to change
  color without restating its typography.
