);
@@ -37,14 +38,11 @@ export function LinkExternalDemo() {
);
}
-export function LinkCurrentVariantDemo() {
+export function LinkColorInheritanceDemo() {
return (
);
}
@@ -62,7 +60,7 @@ const CustomRouterLink = ({
export function LinkRenderDemo() {
return (
-
} variant="inline">
+
}>
Dashboard (via render)
}
- variant="inline"
>
Cloudflare Docs
diff --git a/packages/kumo-docs-astro/src/pages/components/link.mdx b/packages/kumo-docs-astro/src/pages/components/link.mdx
index 08fbc44c90..af7c75698a 100644
--- a/packages/kumo-docs-astro/src/pages/components/link.mdx
+++ b/packages/kumo-docs-astro/src/pages/components/link.mdx
@@ -1,7 +1,7 @@
---
layout: ~/layouts/MdxDocLayout.astro
title: "Link"
-description: "A styled anchor component for inline text links with multiple variants and composition support."
+description: "A styled anchor component for inline text links with composition support."
sourceFile: "components/link"
---
@@ -11,7 +11,7 @@ import {
LinkBasicDemo,
LinkInParagraphDemo,
LinkExternalDemo,
- LinkCurrentVariantDemo,
+ LinkColorInheritanceDemo,
LinkRenderDemo,
} from "~/components/demos/LinkDemo";
@@ -52,7 +52,9 @@ import { Link } from "@cloudflare/kumo/components/link";
### Basic Link
- The default Link component renders an underlined anchor with primary color styling.
+ Link renders an underlined anchor that inherits its color from the
+ surrounding text. There is a single visual style — Kumo intentionally does
+ not ship a distinct "brand blue" link color.
```tsx
@@ -146,9 +148,7 @@ export default function Example() {
return (
<>
{/* Force a specific router link (bypasses LinkProvider) */}
-
} variant="inline">
- Dashboard
-
+
}>Dashboard
{/* Force a plain anchor (bypasses LinkProvider) */}
} href="https://example.com" target="_blank" rel="noopener noreferrer">
@@ -184,14 +184,14 @@ export default function Example() {
-### Current Variant (Color Inheritance)
+### Color Inheritance
- The `current` variant inherits color from its parent, useful for links within
- colored contexts like alerts.
+ Link inherits color from its parent, which makes it work naturally inside
+ colored contexts such as alerts and error messages.
-
-
+
+
### Composition with render prop
@@ -228,11 +228,12 @@ export default function Example() {
| variant |
-
- "inline" | "current" | "plain"
+ | "default" |
+ "default" |
+
+ Visual style. Currently a single underlined variant that inherits
+ color from the surrounding text.
|
- "inline" |
- Visual style variant |
| render |
@@ -277,41 +278,6 @@ export default function Example() {
-### Variants
-
-
-
-
-
- | Variant |
- Description |
- Use Case |
-
-
-
-
- | inline |
- Primary color with underline |
- Default for inline text links |
-
-
- | current |
-
- Inherits parent text color with underline
- |
-
- Links within colored contexts (alerts, errors)
- |
-
-
- | plain |
- Primary color without underline |
- Navigation links, menus, footers |
-
-
-
-
-
### Link.ExternalIcon
@@ -336,19 +302,16 @@ export default function Example() {
- ### When to Use Each Variant
+ ### Visual Style
-
- inline: Default choice for links within body text
-
- -
- current: Links inside alerts, banners, or other
- colored containers
+ Links are underlined and inherit their color from the surrounding
+ text — there is no separate "brand blue" link color
-
- plain: Navigation menus, footers, or where underlines
- are distracting
+ Inside colored contexts (alerts, errors, callouts), the link
+ automatically picks up the parent color
@@ -402,7 +365,7 @@ export default function Example() {
The external icon has `aria-hidden="true"` - add descriptive text for
screen readers
-
Ensure sufficient color contrast for all variants
+
Ensure sufficient color contrast with the surrounding text
Use descriptive link text (avoid "click here")
diff --git a/packages/kumo-figma/src/generators/link.ts b/packages/kumo-figma/src/generators/link.ts
index 90d9cebc03..d2b3174ff0 100644
--- a/packages/kumo-figma/src/generators/link.ts
+++ b/packages/kumo-figma/src/generators/link.ts
@@ -4,10 +4,9 @@
* Generates a single Link ComponentSet in Figma with variant property.
* Reads variant definitions from component-registry.json (the source of truth).
*
- * Link is a text-only component with three variants:
- * - inline: Default underlined link (text-primary) with semi-transparent underline
- * - current: Inherits color from parent (text-current) with semi-transparent underline
- * - plain: No underline decoration (text-primary)
+ * Link is a text-only component with a single visual style:
+ * - default: Underlined link that inherits color from parent text
+ * (text-current) with semi-transparent underline
*
* Underline opacity (from .link-current CSS class in kumo-binding.css):
* - Light mode: 35% opacity
@@ -90,11 +89,11 @@ export function getLinkParsedVariantStyles(variant: string) {
variant,
classes,
description: variantProp.descriptions[variant] || "",
- // Link uses text-primary or text-current classes
hasUnderline: classes.includes("underline"),
// Check if variant uses link-current class (has semi-transparent underline)
hasLinkCurrentClass: classes.includes("link-current"),
- textColor: classes.includes("text-current") ? "current" : "primary",
+ // Link always renders in the surrounding text color (text-current)
+ textColor: classes.includes("text-current") ? "current" : "default",
};
}
@@ -128,11 +127,12 @@ export function getAllLinkVariantData() {
/**
* Create a single Link component with the specified variant
*
- * For variants with underlines (inline, current), we create a custom underline
- * as a separate line element to support the semi-transparent opacity that
- * matches the CSS implementation (.link-current class).
+ * Link renders an underlined text node that inherits its color from the
+ * surrounding text. The underline is created as a separate line element so
+ * we can preserve the semi-transparent opacity from the CSS `.link-current`
+ * class.
*
- * @param variant - The variant name (inline, current, plain)
+ * @param variant - The variant name (currently only "default")
* @param mode - Color mode for underline opacity (light = 35%, dark = 65%)
*/
async function createLinkComponent(
@@ -161,15 +161,8 @@ async function createLinkComponent(
const textNode = await createTextNode("Link text", FONT_SIZE.base, 400);
textNode.name = "Label";
- // Get the appropriate text color variable
- let textColorVar;
- if (variantData.textColor === "current") {
- // For "current" variant, use default text color in Figma demo
- textColorVar = getVariableByName(VAR_NAMES.text.default);
- } else {
- // For inline and plain, use link/brand color
- textColorVar = getVariableByName(VAR_NAMES.text.link);
- }
+ // Link inherits parent color; use the default text variable for the Figma preview.
+ const textColorVar = getVariableByName(VAR_NAMES.text.default);
// Apply text color
if (textColorVar) {
@@ -234,6 +227,10 @@ async function createLinkComponent(
* Creates a single "Link" ComponentSet with variants derived from
* component-registry.json. Creates both light and dark mode sections.
*
+ * `figma.combineAsVariants` requires at least two components. Link currently
+ * ships a single variant, so when only one variant exists we fall back to
+ * placing the bare component in each section without combining.
+ *
* @param page - Target page for components
* @param startY - Y position to start placing the section
* @returns The Y position after this section (for next section placement)
@@ -275,15 +272,18 @@ export async function generateLinkComponents(
components.push(component);
}
- // Combine all variants into a single ComponentSet
- const componentSet = figma.combineAsVariants(components, page);
- componentSet.name = "Link";
- componentSet.description =
- "Link component with variant styles for navigation";
+ // Combine variants into a single ComponentSet. `combineAsVariants` requires
+ // 2+ components; with a single variant we keep the bare component.
+ const primary: ComponentNode | ComponentSetNode =
+ components.length > 1
+ ? figma.combineAsVariants(components, page)
+ : components[0];
+ primary.name = "Link";
+ primary.description = "Link component with variant styles for navigation";
// Calculate content dimensions (add label column width)
- const contentWidth = componentSet.width + labelColumnWidth;
- const contentHeight = componentSet.height;
+ const contentWidth = primary.width + labelColumnWidth;
+ const contentHeight = primary.height;
// Content Y offset to make room for title inside frame
const contentYOffset = SECTION_TITLE.height;
@@ -302,10 +302,10 @@ export async function generateLinkComponents(
contentHeight + SECTION_PADDING * 2 + contentYOffset,
);
- // Move ComponentSet into light section frame
- lightSection.frame.appendChild(componentSet);
- componentSet.x = SECTION_PADDING + labelColumnWidth;
- componentSet.y = SECTION_PADDING + contentYOffset;
+ // Move primary node into light section frame
+ lightSection.frame.appendChild(primary);
+ primary.x = SECTION_PADDING + labelColumnWidth;
+ primary.y = SECTION_PADDING + contentYOffset;
// Add row labels to light section
for (const label of rowLabels) {
diff --git a/packages/kumo/src/components/link/link.test.tsx b/packages/kumo/src/components/link/link.test.tsx
index 92b735febc..d6fdbf9076 100644
--- a/packages/kumo/src/components/link/link.test.tsx
+++ b/packages/kumo/src/components/link/link.test.tsx
@@ -17,54 +17,29 @@ describe("Link", () => {
expect(() => createElement(Link, props)).not.toThrow();
});
- it("should apply inline variant classes", () => {
- expect(KUMO_LINK_VARIANTS.variant.inline.classes).toContain(
- "text-kumo-link",
- );
- expect(KUMO_LINK_VARIANTS.variant.inline.classes).toContain("underline");
- expect(KUMO_LINK_VARIANTS.variant.inline.classes).toContain("link-current");
+ it("exposes a single 'default' variant", () => {
+ expect(Object.keys(KUMO_LINK_VARIANTS.variant)).toEqual(["default"]);
});
- it("should apply current variant classes", () => {
- expect(KUMO_LINK_VARIANTS.variant.current.classes).toContain(
- "text-current",
- );
- expect(KUMO_LINK_VARIANTS.variant.current.classes).toContain("underline");
- expect(KUMO_LINK_VARIANTS.variant.current.classes).toContain(
+ it("applies default variant classes: current color + underline", () => {
+ expect(KUMO_LINK_VARIANTS.variant.default.classes).toContain("text-current");
+ expect(KUMO_LINK_VARIANTS.variant.default.classes).toContain("underline");
+ expect(KUMO_LINK_VARIANTS.variant.default.classes).toContain(
"link-current",
);
});
- it("should apply plain variant classes", () => {
- expect(KUMO_LINK_VARIANTS.variant.plain.classes).toContain(
- "text-kumo-link",
- );
- expect(KUMO_LINK_VARIANTS.variant.plain.classes).not.toContain("underline");
- });
-
- it("should render with inline variant", () => {
- const props = {
- href: "#",
- variant: "inline" as const,
- children: "Inline link",
- };
- expect(() => createElement(Link, props)).not.toThrow();
- });
-
- it("should render with current variant", () => {
- const props = {
- href: "#",
- variant: "current" as const,
- children: "Current link",
- };
- expect(() => createElement(Link, props)).not.toThrow();
+ it("does not ship a brand-blue link color", () => {
+ for (const { classes } of Object.values(KUMO_LINK_VARIANTS.variant)) {
+ expect(classes).not.toContain("text-kumo-link");
+ }
});
- it("should render with plain variant", () => {
+ it("should render with default variant", () => {
const props = {
href: "#",
- variant: "plain" as const,
- children: "Plain link",
+ variant: "default" as const,
+ children: "Default link",
};
expect(() => createElement(Link, props)).not.toThrow();
});
@@ -79,13 +54,13 @@ describe("Link", () => {
});
it("should generate variant classes via linkVariants helper", () => {
- expect(linkVariants({ variant: "inline" })).toContain("text-kumo-link");
- expect(linkVariants({ variant: "current" })).toContain("text-current");
- expect(linkVariants({ variant: "plain" })).toContain("text-kumo-link");
+ expect(linkVariants({ variant: "default" })).toContain("text-current");
+ expect(linkVariants({ variant: "default" })).toContain("underline");
});
- it("should default to inline variant", () => {
- expect(linkVariants()).toContain("text-kumo-link");
+ it("should default to the 'default' variant", () => {
+ expect(linkVariants()).toBe(linkVariants({ variant: "default" }));
+ expect(linkVariants()).toContain("text-current");
expect(linkVariants()).toContain("underline");
});
@@ -110,7 +85,6 @@ describe("Link", () => {
const customAnchor = createElement("a", { href: "/dashboard" });
const props = {
render: customAnchor,
- variant: "inline" as const,
children: "Dashboard",
};
expect(() => createElement(Link, props)).not.toThrow();
diff --git a/packages/kumo/src/components/link/link.tsx b/packages/kumo/src/components/link/link.tsx
index 9d4012b9fd..cd98fd96f7 100644
--- a/packages/kumo/src/components/link/link.tsx
+++ b/packages/kumo/src/components/link/link.tsx
@@ -38,31 +38,27 @@ const ExternalIcon = (props: SVGProps) => (
ExternalIcon.displayName = "Link.ExternalIcon";
-/** Link variant definitions mapping variant names to their Tailwind classes. */
+/**
+ * Link variant definitions.
+ *
+ * Link intentionally exposes a single visual style: an underlined anchor that
+ * inherits color from its parent text (typically rendering as the default
+ * foreground color, e.g. black on light mode). This keeps links consistent
+ * across the product and avoids competing "brand blue" link colors.
+ */
export const KUMO_LINK_VARIANTS = {
variant: {
- inline: {
- classes:
- // text-kumo-link provides defensive color that won't be overridden by global `a` styles
- "text-kumo-link underline underline-offset-[0.15em] decoration-[0.0625em] link-current transition-colors",
- description: "Inline text link that flows with content",
- },
- current: {
+ default: {
classes:
"text-current underline underline-offset-[0.15em] decoration-[0.0625em] link-current transition-colors",
- description: "Link that inherits color from parent text",
- },
- plain: {
- classes:
- // text-kumo-link provides defensive color that won't be overridden by global `a` styles
- "text-kumo-link hover:text-kumo-link/70 transition-colors",
- description: "Link without underline decoration",
+ description:
+ "Underlined link that inherits color from parent text (default foreground)",
},
},
} as const;
export const KUMO_LINK_DEFAULT_VARIANTS = {
- variant: "inline",
+ variant: "default",
} as const;
export type KumoLinkVariant = keyof typeof KUMO_LINK_VARIANTS.variant;
@@ -70,10 +66,11 @@ export type KumoLinkVariant = keyof typeof KUMO_LINK_VARIANTS.variant;
export interface KumoLinkVariantsProps {
/**
* Visual style of the link.
- * - `"inline"` — Inline text link that flows with content
- * - `"current"` — Link that inherits color from parent text
- * - `"plain"` — Link without underline decoration
- * @default "inline"
+ *
+ * Link currently exposes a single `"default"` variant: an underlined anchor
+ * that inherits the surrounding text color. The prop is retained so the
+ * component conforms to the Kumo variant standard and remains extensible.
+ * @default "default"
*/
variant?: KumoLinkVariant;
}
@@ -124,6 +121,10 @@ export type LinkProps = useRender.ComponentProps<"a"> &
* accessibility. Routing behavior belongs in the application layer, via
* either the `render` prop or a `LinkProvider`.
*
+ * Visually, Link renders as an underlined anchor that inherits its color from
+ * the surrounding text. There is a single `variant` (`"default"`) — Kumo does
+ * not ship a distinct "brand blue" link style.
+ *
* - Without `render`: renders via LinkProvider (default `` or configured component)
* - With `render`: merges props onto the provided element with proper ref/event handling
*
@@ -141,9 +142,7 @@ export type LinkProps = useRender.ComponentProps<"a"> &
*
* @example Composition with React Router via render prop
* ```tsx
- * } variant="inline">
- * Dashboard
- *
+ * }>Dashboard
* ```
*
* @example Composition via LinkProvider (recommended for app-wide routing)
@@ -159,7 +158,7 @@ export type LinkProps = useRender.ComponentProps<"a"> &
* ```
*/
const LinkBase = forwardRef(function Link(
- { className, variant = "inline", render, ...props },
+ { className, variant = "default", render, ...props },
ref,
) {
const LinkComponent = useLinkComponent();