diff --git a/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart b/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart
index 20588ea2..b5bb3927 100644
--- a/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart
+++ b/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart
@@ -164,8 +164,10 @@ class GalleryTypographyPage extends StatelessWidget {
onPressed: () =>
showToast(context, message: 'High contrast activated'),
),
+ // Two spellings of the same state: a null callback disables the
+ // link exactly as `enabled: false` does.
FortalLink('Disabled', enabled: false, onPressed: () {}),
- const FortalLink('Inert'),
+ const FortalLink('Disabled (no callback)'),
FortalLink(
'Documentation',
linkUrl: Uri.parse('https://docs.page/btwld/remix/fortal'),
diff --git a/apps/dashboard/test/app_smoke_test.dart b/apps/dashboard/test/app_smoke_test.dart
index b0755d9d..b57d4cda 100644
--- a/apps/dashboard/test/app_smoke_test.dart
+++ b/apps/dashboard/test/app_smoke_test.dart
@@ -643,12 +643,12 @@ void main() {
for (final underline in FortalLinkUnderline.values) {
expect(find.text(enumLabel(underline)), findsOneWidget);
}
- expect(find.text('Inert'), findsOneWidget);
expect(find.text('Disabled'), findsOneWidget);
+ expect(find.text('Disabled (no callback)'), findsOneWidget);
expect(tester.takeException(), isNull);
});
- testWidgets('an actionable gallery link activates, an inert one does not', (
+ testWidgets('an actionable gallery link activates, a disabled one does not', (
tester,
) async {
tester.view.physicalSize = const Size(1440, 900);
@@ -668,9 +668,9 @@ void main() {
await tester.pump();
expect(find.text('Always link activated'), findsOneWidget);
- final inert = find.text('Inert');
- await tester.ensureVisible(inert);
- await tester.tap(inert, warnIfMissed: false);
+ final disabled = find.text('Disabled (no callback)');
+ await tester.ensureVisible(disabled);
+ await tester.tap(disabled, warnIfMissed: false);
await tester.pump();
expect(tester.takeException(), isNull);
diff --git a/apps/playground/lib/registry/entries/typography_entry.dart b/apps/playground/lib/registry/entries/typography_entry.dart
index a2a1e2c6..94293978 100644
--- a/apps/playground/lib/registry/entries/typography_entry.dart
+++ b/apps/playground/lib/registry/entries/typography_entry.dart
@@ -77,26 +77,30 @@ Widget buildTypographyExample() {
underline: FortalLinkUnderline.none,
onPressed: () {},
),
+ // Two spellings of the same state: a null callback disables the
+ // link exactly as `enabled: false` does.
FortalLink('Disabled', enabled: false, onPressed: () {}),
- const FortalLink('Inert'),
+ const FortalLink('Disabled (no callback)'),
],
),
const SizedBox(height: 24),
const _SectionLabel('Accent and high contrast'),
const SizedBox(height: 12),
- const Wrap(
+ Wrap(
spacing: 18,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
- FortalText('Accent text', accent: true),
- FortalText(
+ const FortalText('Accent text', accent: true),
+ const FortalText(
'Accent high contrast',
accent: true,
highContrast: true,
),
- FortalCode.soft('accent code', highContrast: true),
- FortalLink('Accent link', highContrast: true),
+ const FortalCode.soft('accent code', highContrast: true),
+ // Actionable on purpose: this row is about accent colour, and a
+ // callback-less link would show the disabled treatment instead.
+ FortalLink('Accent link', highContrast: true, onPressed: () {}),
],
),
const SizedBox(height: 24),
diff --git a/docs.json b/docs.json
index fab3b061..e1d83b63 100644
--- a/docs.json
+++ b/docs.json
@@ -129,6 +129,10 @@
"title": "IconButton",
"href": "/components/icon_button"
},
+ {
+ "title": "Link",
+ "href": "/components/link"
+ },
{
"title": "Menu",
"href": "/components/menu"
diff --git a/docs/components/link.mdx b/docs/components/link.mdx
new file mode 100644
index 00000000..74fb88a8
--- /dev/null
+++ b/docs/components/link.mdx
@@ -0,0 +1,330 @@
+---
+title: Link
+description: Styled text that navigates when activated
+keywords: [flutter, remix, link, anchor, navigation, href, hyperlink]
+---
+
+Text that takes the user somewhere else.
+
+## When to use this
+
+- **Navigation**: Move to another route, document, or external destination
+- **Inline references**: Link a word or phrase inside a paragraph
+- **Secondary actions that navigate**: "View all", "Read the docs", "Learn more"
+
+Reach for `RemixButton` instead when activation changes state in place — submitting,
+toggling, opening a dialog. The distinction is not cosmetic: a link publishes the
+Link role and activates on Enter only, while a button publishes the Button role and
+also activates on Space.
+
+## Basic implementation
+
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:remix/remix.dart';
+
+class LinkExample extends StatelessWidget {
+ const LinkExample({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ spacing: 16,
+ children: [
+ RemixLink(
+ label: 'Read the docs',
+ style: style,
+ onPressed: () => debugPrint('navigate'),
+ ),
+ // No callback: a disabled link, same as `enabled: false`.
+ const RemixLink(label: 'Coming soon'),
+ ],
+ );
+ }
+
+ LinkStyler get style {
+ return LinkStyler()
+ .labelColor(Colors.indigo)
+ .onHovered(
+ LinkStyler().label(
+ TextStyler()
+ .decoration(TextDecoration.underline)
+ .decorationColor(Colors.indigo),
+ ),
+ );
+ }
+}
+```
+
+
+## Navigation stays yours
+
+`RemixLink` never launches anything. `onPressed` performs the navigation, so the
+component works the same with a router, a URL launcher, or a scroll controller.
+
+`linkUrl` is assistive metadata that rides along on the semantics node. On Flutter
+web it also becomes an anchor `href`, so omit it when `onPressed` already navigates —
+otherwise a single click has two navigation owners.
+
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:remix/remix.dart';
+
+class LinkUrlExample extends StatelessWidget {
+ const LinkUrlExample({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return RemixLink(
+ label: 'Remix on GitHub',
+ // Safe here: the callback hands off to the platform rather than routing
+ // in-app, so the web anchor and the callback agree on the destination.
+ linkUrl: Uri.parse('https://github.com/btwld/remix'),
+ semanticHint: 'Opens in a new window',
+ onPressed: () => debugPrint('launch'),
+ );
+ }
+}
+```
+
+
+## Two ways to disable, one behaviour
+
+`onPressed: null` and `enabled: false` both disable the link, exactly as a null
+callback disables any other Flutter control. A disabled link announces itself as
+unavailable and gives up its Link role, destination, focus stop, and tap action.
+
+For text that was never meant to be followed, use ordinary text rather than a
+link with no callback.
+
+## Fortal widgets
+
+Remix includes a Fortal-themed widget for this component:
+
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:remix_fortal/remix_fortal.dart';
+
+class FortalLinkExample extends StatelessWidget {
+ const FortalLinkExample({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ spacing: 16,
+ children: [
+ FortalLink('Auto', onPressed: () {}),
+ FortalLink(
+ 'Always',
+ underline: FortalLinkUnderline.always,
+ onPressed: () {},
+ ),
+ FortalLink(
+ 'On hover',
+ underline: FortalLinkUnderline.hover,
+ onPressed: () {},
+ ),
+ FortalLink(
+ 'None',
+ underline: FortalLinkUnderline.none,
+ onPressed: () {},
+ ),
+ ],
+ );
+ }
+}
+```
+
+
+
+ See the [fortalLinkStyle source code](https://github.com/btwld/remix/blob/main/packages/remix_fortal/lib/src/recipes/link.dart) for all available options.
+
+
+## Constructor
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:remix/remix.dart';
+
+RemixLink remixLinkConstructor({
+ Key? key,
+ String? label,
+ Widget? child,
+ VoidCallback? onPressed,
+ bool enabled = true,
+ Uri? linkUrl,
+ FocusNode? focusNode,
+ bool autofocus = false,
+ bool enableFeedback = true,
+ MouseCursor mouseCursor = SystemMouseCursors.click,
+ String? semanticLabel,
+ String? semanticHint,
+ bool excludeSemantics = false,
+ LinkStyler style = const LinkStyler.create(),
+ LinkSpec? styleSpec,
+}) => throw UnimplementedError();
+```
+
+
+## Properties
+### Widget Properties
+
+#### `label` → `String?`
+
+Optional. The link text, rendered with the resolved label style. Either `label` or
+`child` must be provided.
+
+#### `child` → `Widget?`
+
+Optional. Arbitrary link content used instead of `label`. It inherits the resolved
+text and icon themes, so an icon beside the text picks up the link colour.
+
+#### `onPressed` → `VoidCallback?`
+
+Optional. Performs the navigation. A null callback disables the link.
+
+#### `enabled` → `bool`
+
+Optional. Whether an otherwise actionable link may activate. Defaults to `true`.
+
+#### `linkUrl` → `Uri?`
+
+Optional. Destination exposed through Link semantics. Never launched.
+
+#### `focusNode` → `FocusNode?`
+
+Optional. Caller-owned focus node.
+
+#### `autofocus` → `bool`
+
+Optional. Whether the link requests focus when first built. Defaults to `false`.
+
+#### `enableFeedback` → `bool`
+
+Optional. Whether accepted activations provide platform feedback. Defaults to `true`.
+
+#### `mouseCursor` → `MouseCursor`
+
+Optional. Cursor shown while the link is actionable. Defaults to `SystemMouseCursors.click`.
+
+#### `semanticLabel` → `String?`
+
+Optional. Accessible name that replaces the visible text for screen readers.
+
+#### `semanticHint` → `String?`
+
+Optional. Describes a non-obvious result of following the link.
+
+#### `excludeSemantics` → `bool`
+
+Optional. Hides the link and its subtree from semantics. Defaults to `false`.
+
+#### `style` → `LinkStyler`
+
+Optional. The style configuration for the link.
+
+#### `styleSpec` → `LinkSpec?`
+
+Optional. A pre-resolved style spec that bypasses style resolution.
+
+#### `key` → `Key?`
+
+Optional. Controls how one widget replaces another widget in the tree.
+
+### Style Methods
+
+#### `label(TextStyler value)`
+
+Configures the label text style using a TextStyler.
+
+#### `labelColor(Color value)`
+
+Sets label/text color.
+
+#### `labelFontSize(double value)`
+
+Sets label/text font size.
+
+#### `labelFontWeight(FontWeight value)`
+
+Sets label/text font weight.
+
+#### `labelDecoration(TextDecoration value)`
+
+Sets label/text decoration (underline, strikethrough, etc.).
+
+#### `labelDecorationColor(Color value)`
+
+Sets label/text decoration color.
+
+#### `labelStyle(TextStyleMix value)`
+
+Sets label/text style using TextStyleMix directly.
+
+#### `labelFontStyle(FontStyle value)`
+
+Sets label/text font style (italic/normal).
+
+#### `labelFontFamily(String value)`
+
+Sets label/text font family.
+
+#### `labelLetterSpacing(double value)`
+
+Sets label/text letter spacing.
+
+#### `labelWordSpacing(double value)`
+
+Sets label/text word spacing.
+
+#### `labelHeight(double value)`
+
+Sets label/text line height.
+
+#### `padding(EdgeInsetsGeometryMix value)`
+
+Sets padding.
+
+#### `margin(EdgeInsetsGeometryMix value)`
+
+Sets margin.
+
+#### `borderRadius(BorderRadiusGeometryMix radius)`
+
+Sets border radius, used by the focus ring.
+
+#### `decoration(DecorationMix value)`
+
+Sets decoration.
+
+#### `foregroundDecoration(DecorationMix value)`
+
+Sets a foreground decoration painted on top of the component.
+
+#### `alignment(Alignment value)`
+
+Sets container alignment.
+
+#### `constraints(BoxConstraintsMix value)`
+
+Sets constraints.
+
+#### `transform(Matrix4 value, AlignmentGeometry alignment = Alignment.center)`
+
+Applies a matrix transformation to the component.
+
+#### `animate(AnimationConfig value)`
+
+Sets animation.
+
+#### `wrap(WidgetModifierConfig value)`
+
+Applies widget modifiers such as clipping, opacity, or scaling.
+
+#### `call({Key? key, String? label, Widget? child, VoidCallback? onPressed, ...})`
+
+Creates a `RemixLink` widget with this style applied.
diff --git a/docs/fortal/typography.mdx b/docs/fortal/typography.mdx
index afa88bd2..cd8c9b70 100644
--- a/docs/fortal/typography.mdx
+++ b/docs/fortal/typography.mdx
@@ -30,7 +30,7 @@ tokens is the equivalent there.
| `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** when `onPressed` is set |
+| `FortalLink` | Text that navigates | `link` **only** while enabled and given an `onPressed` |
`FortalText` and `FortalCode` leave `size` and `weight` null by default so an
omitted size inherits the ambient `DefaultTextStyle`, matching a Radix `Text`
@@ -192,13 +192,20 @@ 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 inert links
+## 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`.
Every upstream underline rule is gated behind `:where(:any-link, button)`, so a
-`FortalLink` without `onPressed` is **inert**: styled accent text with no focus
-stop, no link role, and no activation. Give it `onPressed` and it becomes a real
-link — focusable, activatable with pointer, Enter, and Space, and underlined
-according to `underline`.
+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 |
|-------------|---------------------------|
@@ -233,7 +240,7 @@ class LinkExample extends StatelessWidget {
semanticHint: 'Opens the Fortal documentation',
onPressed: onOpenDocs,
),
- const FortalLink('Not a link yet'),
+ const FortalLink('Not available yet'),
],
);
}
@@ -247,14 +254,15 @@ launcher dependency. Passing `linkUrl` without `onPressed` asserts, because an
announced destination with no activation is a broken promise to assistive
technology.
-Set `enabled: false` to keep the callback and still refuse focus and activation.
+`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`, `FortalKbd`, and
-`FortalLink` return a `BadgeStyler` and take a `BuildContext`, because their
-geometry is em-relative to the resolved font size.
+`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.
```dart
diff --git a/packages/remix/README.md b/packages/remix/README.md
index b2662129..0c4c441b 100644
--- a/packages/remix/README.md
+++ b/packages/remix/README.md
@@ -243,6 +243,7 @@ Remix provides a comprehensive set of production-ready components:
### Layout & Navigation
- **Tabs** - Tabbed navigation
- **Accordion** - Collapsible content sections
+- **Link** - Styled text that navigates, with the Link role and Enter activation
- **Menu** - Context menus and dropdowns
- **SegmentedControl** - Equal-segment controlled single selection
diff --git a/packages/remix/lib/remix.dart b/packages/remix/lib/remix.dart
index a82e733c..a674792c 100644
--- a/packages/remix/lib/remix.dart
+++ b/packages/remix/lib/remix.dart
@@ -17,6 +17,7 @@ export 'src/components/checkbox/checkbox.dart';
export 'src/components/data_list/data_list.dart';
export 'src/components/data_table/data_table.dart';
export 'src/components/divider/divider.dart';
+export 'src/components/link/link.dart';
export 'src/components/menu/menu.dart';
export 'src/components/popover/popover.dart';
export 'src/components/progress/progress.dart';
diff --git a/packages/remix/lib/src/components/link/link.dart b/packages/remix/lib/src/components/link/link.dart
new file mode 100644
index 00000000..c78d4805
--- /dev/null
+++ b/packages/remix/lib/src/components/link/link.dart
@@ -0,0 +1,15 @@
+library remix_link;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+import 'package:mix/mix.dart';
+import 'package:mix_annotations/mix_annotations.dart';
+import 'package:naked_ui/naked_ui.dart';
+
+import '../../rendering/remix_box_effects.dart';
+import '../../style/style.dart';
+import '../../utilities/remix_style.dart';
+
+part 'link_spec.dart';
+part 'link_widget.dart';
+part 'link.g.dart';
diff --git a/packages/remix/lib/src/components/link/link.g.dart b/packages/remix/lib/src/components/link/link.g.dart
new file mode 100644
index 00000000..43a1df85
--- /dev/null
+++ b/packages/remix/lib/src/components/link/link.g.dart
@@ -0,0 +1,719 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'link.dart';
+
+// **************************************************************************
+// SpecGenerator
+// **************************************************************************
+
+mixin _$LinkSpec implements Spec, Diagnosticable {
+ StyleSpec get container;
+ StyleSpec get label;
+ RemixBoxEffectsSpec? get containerEffects;
+
+ @override
+ Type get type => LinkSpec;
+
+ @override
+ LinkSpec copyWith({
+ StyleSpec? container,
+ StyleSpec? label,
+ RemixBoxEffectsSpec? containerEffects,
+ }) {
+ return LinkSpec(
+ container: container ?? this.container,
+ label: label ?? this.label,
+ containerEffects: containerEffects ?? this.containerEffects,
+ );
+ }
+
+ @override
+ LinkSpec lerp(LinkSpec? other, double t) {
+ return LinkSpec(
+ container: container.lerp(other?.container, t),
+ label: label.lerp(other?.label, t),
+ containerEffects: MixOps.lerpSnap(
+ containerEffects,
+ other?.containerEffects,
+ t,
+ ),
+ );
+ }
+
+ @override
+ List