Typography
Fortal's Text, Heading, Code, Kbd, and Link widgets on one shared nine-step scale
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.
For visual reference, open the light matrix or dark matrix. These Orla 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. 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
Basic implementation
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',
),
],
),
],
);
}
}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
Code and Kbd variants
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'),
],
);
}
}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.
Heading level and size
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,
),
],
);
}
}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.
Links
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'),
],
);
}
}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.
Recipe customization
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));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.
softWrapandtruncatecontrol flow; use layout constraints to select responsive values. FortalCodeandFortalKbdare standalone boxes, not inline text spans. Compose them in aWrapor 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
softvariant 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.