diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md
index e34586f6591068..aeba1b0d7a250a 100644
--- a/packages/ui/CHANGELOG.md
+++ b/packages/ui/CHANGELOG.md
@@ -6,6 +6,7 @@
- Add `Text` primitive with predefined typographic variants (`heading-2xl` through `heading-sm`, `body-xl` through `body-sm`) built on design tokens ([#75870](https://github.com/WordPress/gutenberg/pull/75870)).
- Add `Card` and `CollapsibleCard` primitives ([#76252](https://github.com/WordPress/gutenberg/pull/76252)).
+- Add `Link` primitive ([#76013](https://github.com/WordPress/gutenberg/pull/76013)).
### Bug Fixes
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 57d2e98fc9bb61..75765c8a38212f 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -6,6 +6,7 @@ export * as Dialog from './dialog';
export * from './form/primitives';
export * from './icon';
export * from './icon-button';
+export * from './link';
export * as Notice from './notice';
export * from './stack';
export * as Tabs from './tabs';
diff --git a/packages/ui/src/link/index.ts b/packages/ui/src/link/index.ts
new file mode 100644
index 00000000000000..63b88256ef476a
--- /dev/null
+++ b/packages/ui/src/link/index.ts
@@ -0,0 +1 @@
+export { Link } from './link';
diff --git a/packages/ui/src/link/link.tsx b/packages/ui/src/link/link.tsx
new file mode 100644
index 00000000000000..658d89d3fd81f2
--- /dev/null
+++ b/packages/ui/src/link/link.tsx
@@ -0,0 +1,73 @@
+import { useRender, mergeProps } from '@base-ui/react';
+import clsx from 'clsx';
+import { forwardRef } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { type LinkProps } from './types';
+import resetStyles from '../utils/css/resets.module.css';
+import focusStyles from '../utils/css/focus.module.css';
+import styles from './style.module.css';
+
+/**
+ * A styled anchor element with support for semantic color tones and an
+ * unstyled escape hatch.
+ */
+export const Link = forwardRef< HTMLAnchorElement, LinkProps >( function Link(
+ {
+ children,
+ variant = 'default',
+ tone = 'brand',
+ openInNewTab = false,
+ render,
+ className,
+ onClick,
+ ...props
+ },
+ ref
+) {
+ const isInternalAnchor = !! props.href?.startsWith( '#' );
+
+ const handleClick = ( event: React.MouseEvent< HTMLAnchorElement > ) => {
+ if ( openInNewTab && isInternalAnchor ) {
+ event.preventDefault();
+ }
+ onClick?.( event );
+ };
+
+ const element = useRender( {
+ render,
+ defaultTagName: 'a',
+ ref,
+ props: mergeProps< 'a' >( props, {
+ className: clsx(
+ resetStyles[ 'box-sizing' ],
+ focusStyles[ 'outset-ring--focus' ],
+ variant !== 'unstyled' && styles.link,
+ variant !== 'unstyled' && styles[ `is-${ tone }` ],
+ variant === 'unstyled' && styles[ 'is-unstyled' ],
+ openInNewTab && styles[ 'has-link-icon' ],
+ className
+ ),
+ onClick: handleClick,
+ target: openInNewTab ? '_blank' : undefined,
+ children: openInNewTab ? (
+ <>
+
+ { children }
+
+
+ >
+ ) : (
+ children
+ ),
+ } ),
+ } );
+
+ return element;
+} );
diff --git a/packages/ui/src/link/stories/index.story.tsx b/packages/ui/src/link/stories/index.story.tsx
new file mode 100644
index 00000000000000..e609b17e42ab77
--- /dev/null
+++ b/packages/ui/src/link/stories/index.story.tsx
@@ -0,0 +1,92 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Link } from '../index';
+import { Stack } from '../../stack';
+import { Text } from '../../text';
+
+const meta: Meta< typeof Link > = {
+ title: 'Design System/Components/Link',
+ component: Link,
+};
+export default meta;
+
+type Story = StoryObj< typeof Link >;
+
+export const Default: Story = {
+ args: {
+ children: 'Learn more',
+ href: '#',
+ },
+};
+
+/**
+ * Note: `tone` has no effect on `unstyled` variant
+ */
+export const AllTonesAndVariants: Story = {
+ ...Default,
+ argTypes: {
+ tone: {
+ control: false,
+ },
+ variant: {
+ control: false,
+ },
+ },
+ render: ( args ) => (
+
+ { ( [ 'brand', 'neutral' ] as const ).map( ( tone ) =>
+ ( [ 'default', 'unstyled' ] as const ).map( ( variant ) => (
+
+
+ { tone } tone, { variant } variant
+
+
+
+ ) )
+ ) }
+
+ ),
+};
+
+export const Inline: Story = {
+ ...Default,
+ args: {
+ ...Default.args,
+ children: 'inline link',
+ },
+ render: ( args ) => (
+ }>
+ This is a paragraph with an that inherits its
+ typography from the parent Text component.
+
+ ),
+};
+
+/**
+ * When composing `Text` and `Link` via the `render` prop, the order matters:
+ * - ` } />` renders an `` element (Link's default tag wins).
+ * - ` } />` renders a `` element (Text's default tag wins).
+ */
+export const Standalone: Story = {
+ args: {
+ href: '#',
+ },
+ argTypes: {
+ children: {
+ control: false,
+ },
+ },
+ render: ( args ) => (
+
+ }>
+ A standalone link with body-md typography
+
+ }>
+ A standalone link with body-sm typography
+
+
+ ),
+};
diff --git a/packages/ui/src/link/style.module.css b/packages/ui/src/link/style.module.css
new file mode 100644
index 00000000000000..fa1f8f5a5cd2c9
--- /dev/null
+++ b/packages/ui/src/link/style.module.css
@@ -0,0 +1,68 @@
+@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;
+
+@layer wp-ui-components {
+ .link {
+ text-underline-offset: 0.2em;
+ text-decoration-thickness: 0.5px;
+ }
+
+ /* Brand tone */
+ .is-brand,
+ .is-brand:visited {
+ color: var(--wpds-color-fg-interactive-brand);
+ }
+
+ .is-brand:hover,
+ .is-brand:active {
+ color: var(--wpds-color-fg-interactive-brand-active);
+ }
+
+ /* Neutral tone */
+ .is-neutral,
+ .is-neutral:visited {
+ color: var(--wpds-color-fg-interactive-neutral);
+ text-decoration-color: var(--wpds-color-stroke-interactive-neutral);
+ }
+
+ .is-neutral:hover,
+ .is-neutral:active {
+ color: var(--wpds-color-fg-interactive-neutral-active);
+ }
+
+ /* Unstyled variant */
+ .is-unstyled {
+ color: inherit;
+ text-decoration: none;
+ }
+
+ /* Link icon pattern — arrow rendered via ::after pseudo-element to avoid
+ * Twemoji replacement and text-selection issues. Root gets
+ * text-decoration:none; the link-contents span re-applies it.
+ * text-underline-offset is inherited and propagates from .link
+ * automatically. text-decoration-thickness and text-decoration-color
+ * are NOT inherited, so they are explicitly redeclared on
+ * .link-contents (the latter via `inherit` to carry the neutral
+ * tone's custom color token through). */
+ .has-link-icon {
+ text-decoration: none;
+ }
+
+ .link-contents {
+ text-decoration: underline;
+ text-decoration-color: inherit;
+ text-decoration-thickness: 0.5px;
+ }
+
+ .link-icon {
+ margin-inline-start: var(--wpds-dimension-padding-xs);
+ font-weight: var(--wpds-font-weight-regular);
+ }
+
+ .link-icon::after {
+ content: "\2197";
+ }
+
+ .link-icon:dir(rtl)::after {
+ content: "\2196";
+ }
+}
diff --git a/packages/ui/src/link/test/index.test.tsx b/packages/ui/src/link/test/index.test.tsx
new file mode 100644
index 00000000000000..ede6bb158d71c3
--- /dev/null
+++ b/packages/ui/src/link/test/index.test.tsx
@@ -0,0 +1,93 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createRef } from '@wordpress/element';
+import { Link } from '../index';
+
+describe( 'Link', () => {
+ it( 'forwards ref', () => {
+ const ref = createRef< HTMLAnchorElement >();
+
+ render(
+
+ Home
+
+ );
+
+ expect( ref.current ).toBeInstanceOf( HTMLAnchorElement );
+ } );
+
+ describe( 'openInNewTab', () => {
+ it( 'sets target="_blank" when true', () => {
+ render(
+
+ External
+
+ );
+
+ expect( screen.getByRole( 'link' ) ).toHaveAttribute(
+ 'target',
+ '_blank'
+ );
+ } );
+
+ it( 'does not set target="_blank" when false', () => {
+ render( External );
+
+ expect( screen.getByRole( 'link' ) ).not.toHaveAttribute(
+ 'target'
+ );
+ } );
+
+ it( 'renders an accessible arrow indicator', () => {
+ render(
+
+ External
+
+ );
+
+ expect(
+ screen.getByLabelText( '(opens in a new tab)' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'prevents default for internal anchor links', async () => {
+ const user = userEvent.setup();
+ const onClick = jest.fn();
+
+ render(
+
+ Jump
+
+ );
+
+ await user.click( screen.getByRole( 'link' ) );
+
+ expect( onClick ).toHaveBeenCalledTimes( 1 );
+ expect( onClick.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe(
+ true
+ );
+ } );
+
+ it( 'does not prevent default for external links', async () => {
+ const user = userEvent.setup();
+ const onClick = jest.fn();
+
+ render(
+
+ External
+
+ );
+
+ await user.click( screen.getByRole( 'link' ) );
+
+ expect( onClick ).toHaveBeenCalledTimes( 1 );
+ expect( onClick.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe(
+ false
+ );
+ } );
+ } );
+} );
diff --git a/packages/ui/src/link/types.ts b/packages/ui/src/link/types.ts
new file mode 100644
index 00000000000000..8d1e3f76638f89
--- /dev/null
+++ b/packages/ui/src/link/types.ts
@@ -0,0 +1,36 @@
+import { type ReactNode } from 'react';
+import { type ComponentProps } from '../utils/types';
+
+export interface LinkProps extends Omit< ComponentProps< 'a' >, 'target' > {
+ /**
+ * The visual treatment of the link.
+ *
+ * - `default`: Applies tone-based color and underline styles.
+ * - `unstyled`: Strips all visual styles so consumers can bring their own.
+ *
+ * @default "default"
+ */
+ variant?: 'default' | 'unstyled';
+
+ /**
+ * The tone of the link. Tone describes a semantic color intent.
+ * Only applies when `variant` is `default`.
+ *
+ * @default "brand"
+ */
+ tone?: 'brand' | 'neutral';
+
+ /**
+ * Whether to open the link in a new browser tab.
+ * When true, sets `target="_blank"`, appends a visual arrow indicator,
+ * and prevents navigation for internal anchors (`#`-prefixed hrefs).
+ *
+ * @default false
+ */
+ openInNewTab?: boolean;
+
+ /**
+ * The content to be rendered inside the component.
+ */
+ children?: ReactNode;
+}
diff --git a/packages/ui/src/notice/action-link.tsx b/packages/ui/src/notice/action-link.tsx
index 2be24d14f5ca74..8522fb6cf3ca4f 100644
--- a/packages/ui/src/notice/action-link.tsx
+++ b/packages/ui/src/notice/action-link.tsx
@@ -1,28 +1,22 @@
-import { forwardRef } from '@wordpress/element';
-import { useRender, mergeProps } from '@base-ui/react';
import clsx from 'clsx';
+import { forwardRef } from '@wordpress/element';
+import { Link } from '../link';
import type { ActionLinkProps } from './types';
import styles from './style.module.css';
/**
* An action link for use within Notice.Actions.
- *
- * TODO: This should use a shared Link component.
*/
export const ActionLink = forwardRef< HTMLAnchorElement, ActionLinkProps >(
- function NoticeActionLink( { className, render, ...props }, ref ) {
- const element = useRender( {
- defaultTagName: 'a',
- render,
- ref,
- props: mergeProps< 'a' >(
- {
- className: clsx( styles[ 'action-link' ], className ),
- },
- props
- ),
- } );
-
- return element;
+ function NoticeActionLink( { className, ...props }, ref ) {
+ return (
+
+ );
}
);
diff --git a/packages/ui/src/notice/style.module.css b/packages/ui/src/notice/style.module.css
index 5b0ff9db732bf1..9e5b3d95d34dad 100644
--- a/packages/ui/src/notice/style.module.css
+++ b/packages/ui/src/notice/style.module.css
@@ -69,20 +69,6 @@
font-weight: var(--wpds-font-weight-regular);
line-height: var(--wpds-font-line-height-sm);
margin-block: auto;
- text-underline-offset: 0.2em;
- text-decoration-thickness: 0.5px;
- color: var(--wpds-color-fg-interactive-neutral);
- text-decoration-color: var(--wpds-color-stroke-interactive-neutral);
-
- &:visited {
- color: var(--wpds-color-fg-interactive-neutral);
- text-decoration-color: var(--wpds-color-stroke-interactive-neutral);
- }
-
- &:hover,
- &:active {
- color: var(--wpds-color-fg-interactive-neutral-active);
- }
/* Add more horizontal space when following another action link/button */
&:not(:first-child) {
diff --git a/packages/ui/src/notice/types.ts b/packages/ui/src/notice/types.ts
index 9f942fa0414cc3..11f938e3cc205f 100644
--- a/packages/ui/src/notice/types.ts
+++ b/packages/ui/src/notice/types.ts
@@ -3,6 +3,7 @@ import type { ComponentProps } from '../utils/types';
import type { IconProps } from '../icon/types';
import type { ButtonProps } from '../button/types';
import type { IconButtonProps } from '../icon-button/types';
+import type { LinkProps } from '../link/types';
export type NoticeIntent = 'warning' | 'success' | 'error' | 'info' | 'neutral';
@@ -92,7 +93,7 @@ export interface ActionButtonProps
children?: ReactNode;
}
-export interface ActionLinkProps extends ComponentProps< 'a' > {
+export interface ActionLinkProps extends Omit< LinkProps, 'variant' | 'tone' > {
/**
* The content to be rendered inside the component.
*/