diff --git a/components/button-link/README.md b/components/button-link/README.md
new file mode 100644
index 0000000000..4a7a7b7e79
--- /dev/null
+++ b/components/button-link/README.md
@@ -0,0 +1,39 @@
+
+
+# Button link: link die er uit ziet als button
+
+Een link die er uitziet als een button, om gebruikers aan te sporen op de link te klikken en actie te gaan ondernemen.
+
+## Toepassing
+
+Een link button mag alleen gebruikt worden voor navigatie naar een pagina waar je een actie uitvoert, de link klikken mag niet gelijk een _side-effect_ hebben.
+
+Wel:
+
+- Log in met eIDAS (navigeert naar formulier voor inloggen)
+- Maak een afspraak (navigeert naar formulier)
+
+Niet:
+
+- Uitloggen (logt direct uit)
+- Stop de parkeermeter (je mag hierna niet meer parkeren)
+
+## Activeren
+
+Een link button moet op dezelfde manier geactiveerd kunnen worden als een button:
+
+- Klikken
+- `Enter` op toetsenbord
+- `Space` op toetsenbord
+
+Een HTML `a` element wordt standaard niet geactiveerd met `Space`, daarvoor is ondersteunende JavaScript nodig.
+
+## States
+
+- hover
+- focus
+- focus-visible
+
+De link button heeft geen disabled state, net als de normale link: die heeft ook geen disabled state.
+
+De link button heeft geen `visited` state zoals normale links, maar ziet er altijd hetzelfde uit zoals een normale button.
diff --git a/components/button-link/css/index.scss b/components/button-link/css/index.scss
new file mode 100644
index 0000000000..c49bc26861
--- /dev/null
+++ b/components/button-link/css/index.scss
@@ -0,0 +1,26 @@
+/**
+ * @license EUPL-1.2
+ * Copyright (c) 2021 The Knights Who Say NIH! B.V.
+ * Copyright (c) 2021 Gemeente Utrecht
+ */
+
+@import "../../button/bem";
+
+.utrecht-button-link {
+ @extend .utrecht-button;
+
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.utrecht-button-link--hover {
+ @extend .utrecht-button--hover;
+}
+
+.utrecht-button-link--focus {
+ @extend .utrecht-button--focus;
+}
+
+.utrecht-button-link--focus-visible {
+ @extend .utrecht-button--focus-visible;
+}
diff --git a/components/button-link/css/stories/01-default.stories.mdx b/components/button-link/css/stories/01-default.stories.mdx
new file mode 100644
index 0000000000..c9741f09c2
--- /dev/null
+++ b/components/button-link/css/stories/01-default.stories.mdx
@@ -0,0 +1,66 @@
+
+
+import { ArgsTable, Canvas, Meta, Story } from "@storybook/addon-docs";
+import { ButtonLink, defaultArgs } from "../template";
+import "../index.scss";
+
+
+
+# Button link: link die er uit ziet als button
+
+Styling met de `.utrecht-button-link` class naam:
+
+
+
+
diff --git a/components/button-link/css/stories/readme.stories.mdx b/components/button-link/css/stories/readme.stories.mdx
new file mode 100644
index 0000000000..243f297e21
--- /dev/null
+++ b/components/button-link/css/stories/readme.stories.mdx
@@ -0,0 +1,8 @@
+
+
+import { Description, Meta } from "@storybook/addon-docs";
+import document from "../../README.md";
+
+
+
+{document}
diff --git a/components/button-link/css/template.js b/components/button-link/css/template.js
new file mode 100644
index 0000000000..ac49503806
--- /dev/null
+++ b/components/button-link/css/template.js
@@ -0,0 +1,25 @@
+import clsx from 'clsx';
+
+export const defaultArgs = {
+ external: false,
+ hover: false,
+ href: '',
+ focus: false,
+ focusVisible: false,
+ textContent: '',
+};
+
+export const ButtonLink = ({
+ external = false,
+ hover = false,
+ href = '',
+ focus = false,
+ focusVisible = false,
+ textContent = '',
+}) =>
+ `${textContent}`;
diff --git a/components/button-link/react/index.stories.mdx b/components/button-link/react/index.stories.mdx
new file mode 100644
index 0000000000..9d4b71c4fc
--- /dev/null
+++ b/components/button-link/react/index.stories.mdx
@@ -0,0 +1,22 @@
+
+
+import { Canvas, Meta } from "@storybook/addon-docs";
+import { ButtonLink } from "@utrecht/component-library-react";
+import "../css/index.scss";
+
+
+
+# Link that looks like a button
+
+
diff --git a/packages/component-library-css/src/bem.scss b/packages/component-library-css/src/bem.scss
index 862a62e89f..43e24822b7 100644
--- a/packages/component-library-css/src/bem.scss
+++ b/packages/component-library-css/src/bem.scss
@@ -25,6 +25,7 @@
@import "../../../components/breadcrumb/css";
@import "../../../components/button/bem";
@import "../../../components/button-group/css";
+@import "../../../components/button-link/css";
@import "../../../components/checkbox/css";
@import "../../../components/custom-checkbox/css";
@import "../../../components/digid-button/css";
diff --git a/packages/component-library-react/package.json b/packages/component-library-react/package.json
index aef27cf813..3b46257133 100644
--- a/packages/component-library-react/package.json
+++ b/packages/component-library-react/package.json
@@ -41,6 +41,10 @@
"types": "./dist/Button.d.ts",
"default": "./dist/cjs/Button.js"
},
+ "./ButtonLink": {
+ "types": "./dist/ButtonLink.d.ts",
+ "default": "./dist/cjs/ButtonLink.js"
+ },
"./Checkbox": {
"types": "./dist/Checkbox.d.ts",
"default": "./dist/cjs/Checkbox.js"
diff --git a/packages/component-library-react/src/ButtonLink.test.tsx b/packages/component-library-react/src/ButtonLink.test.tsx
new file mode 100644
index 0000000000..a93f184328
--- /dev/null
+++ b/packages/component-library-react/src/ButtonLink.test.tsx
@@ -0,0 +1,252 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createRef } from 'react';
+import { ButtonLink } from './ButtonLink';
+import '@testing-library/jest-dom';
+
+describe('Link that looks like a button', () => {
+ it('renders an link role element', () => {
+ render(Home);
+
+ const link = screen.getByRole('link', { name: 'Home' });
+
+ expect(link).toBeInTheDocument();
+ expect(link).toBeVisible();
+ });
+
+ it('renders an HTML a element', () => {
+ const { container } = render({'https://example.com/'});
+
+ const link = container.querySelector('a:only-child');
+
+ expect(link).toBeInTheDocument();
+ });
+
+ it('is focusable', async () => {
+ const handleFocus = jest.fn();
+
+ render(
+
+ Home
+ ,
+ );
+
+ const link = screen.getByRole('link');
+
+ link?.focus();
+
+ expect(handleFocus).toHaveBeenCalled();
+ });
+
+ it('can be activated with Enter', async () => {
+ const handleClick = jest.fn();
+
+ render(
+
+ Home
+ ,
+ );
+
+ const link = screen.getByRole('link');
+
+ if (link) {
+ link.focus();
+ await userEvent.keyboard('{Enter}');
+ }
+
+ expect(handleClick).toHaveBeenCalled();
+ });
+
+ it('can NOT be activated with Space', async () => {
+ const handleClick = jest.fn();
+
+ render(
+
+ Home
+ ,
+ );
+
+ const link = screen.getByRole('link');
+
+ if (link) {
+ link.focus();
+ await userEvent.keyboard(' ');
+ }
+
+ expect(handleClick).not.toHaveBeenCalled();
+ });
+
+ describe('with button role', () => {
+ it('can render a link with button role element', () => {
+ render(
+
+ Back
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: 'Back' });
+
+ expect(button).toBeInTheDocument();
+ expect(button).toBeVisible();
+ });
+
+ it('can be activated with Enter', async () => {
+ const handleClick = jest.fn();
+
+ render(
+
+ Home
+ ,
+ );
+
+ const link = screen.getByRole('button');
+
+ if (link) {
+ link.focus();
+ await userEvent.keyboard('{Enter}');
+ }
+
+ expect(handleClick).toHaveBeenCalled();
+ });
+
+ it('can be activated with Space', async () => {
+ const handleClick = jest.fn();
+
+ render(
+
+ Home
+ ,
+ );
+
+ const link = screen.getByRole('button');
+
+ if (link) {
+ link.focus();
+ await userEvent.keyboard(' ');
+ }
+
+ expect(handleClick).toHaveBeenCalled();
+ });
+ });
+
+ it('renders a design system BEM class name', () => {
+ const { container } = render();
+
+ const link = container.querySelector(':only-child');
+
+ expect(link).toHaveClass('utrecht-button-link');
+ });
+
+ it('renders rich text content', () => {
+ const { container } = render(
+
+ https:
+ {'//example.com/'}
+ ,
+ );
+
+ const link = container.querySelector(':only-child');
+
+ const richText = link?.querySelector('strong');
+
+ expect(richText).toBeInTheDocument();
+ });
+
+ it('can be hidden', () => {
+ const { container } = render();
+
+ const link = container.querySelector(':only-child');
+
+ expect(link).not.toBeVisible();
+ });
+
+ it('can have a custom class name', () => {
+ const { container } = render({'https://example.com/'});
+
+ const link = container.querySelector(':only-child');
+
+ expect(link).toHaveClass('visited');
+ });
+
+ it('supports ForwardRef in React', () => {
+ const ref = createRef();
+
+ const { container } = render({'https://example.com/'});
+
+ const link = container.querySelector(':only-child');
+
+ expect(ref.current).toBe(link);
+ });
+
+ describe('variant for external links', () => {
+ it('renders a design system BEM class name', () => {
+ const { container } = render();
+
+ const link = container.querySelector(':only-child');
+
+ expect(link).toHaveClass('utrecht-button-link--external');
+ });
+
+ it('prevents sharing referer information', () => {
+ const { container } = render();
+
+ const link = container.querySelector(':only-child');
+
+ expect(link).toHaveAttribute('rel');
+
+ expect(link?.getAttribute('rel')).toContain('noreferrer');
+ });
+
+ it('prevents access to window.opener', () => {
+ const { container } = render();
+
+ const link = container.querySelector(':only-child');
+
+ expect(link).toHaveAttribute('rel');
+
+ expect(link?.getAttribute('rel')).toContain('noopener');
+ });
+
+ it('provides semantic information that the link is external', () => {
+ const { container } = render();
+
+ const link = container.querySelector(':only-child');
+
+ expect(link).toHaveAttribute('rel');
+
+ expect(link?.getAttribute('rel')).toContain('external');
+ });
+ });
+
+ describe('with simulated state', () => {
+ describe('focus variant', () => {
+ it('renders a design system BEM class name', () => {
+ const { container } = render();
+
+ const link = container.querySelector(':only-child');
+
+ expect(link).toHaveClass('utrecht-button-link--focus');
+ });
+ });
+
+ describe('focus-visible variant', () => {
+ it('renders a design system BEM class name', () => {
+ const { container } = render();
+
+ const link = container.querySelector(':only-child');
+
+ expect(link).toHaveClass('utrecht-button-link--focus-visible');
+ });
+ });
+
+ describe('hover variant', () => {
+ it('renders a design system BEM class name', () => {
+ const { container } = render();
+
+ const link = container.querySelector(':only-child');
+
+ expect(link).toHaveClass('utrecht-button-link--hover');
+ });
+ });
+ });
+});
diff --git a/packages/component-library-react/src/ButtonLink.tsx b/packages/component-library-react/src/ButtonLink.tsx
new file mode 100644
index 0000000000..590c7a4715
--- /dev/null
+++ b/packages/component-library-react/src/ButtonLink.tsx
@@ -0,0 +1,77 @@
+/**
+ * @license EUPL-1.2
+ * Copyright (c) 2021 Gemeente Utrecht
+ * Copyright (c) 2021 Robbert Broersma
+ */
+
+import clsx from 'clsx';
+import { AnchorHTMLAttributes, ForwardedRef, forwardRef, KeyboardEvent, PropsWithChildren } from 'react';
+
+interface ButtonLinkProps extends AnchorHTMLAttributes {
+ external?: boolean;
+ focusStyle?: boolean;
+ focusVisibleStyle?: boolean;
+ hoverStyle?: boolean;
+ visitedStyle?: boolean;
+}
+const onKeyDown = (evt: KeyboardEvent) => {
+ if (evt.key === ' ' && typeof (evt.target as HTMLElement)?.click === 'function') {
+ // Prevent other side-effects, such as scrolling
+ evt.preventDefault();
+
+ // Navigate to the link target
+ (evt.target as HTMLElement).click();
+ }
+};
+
+export const ButtonLink = forwardRef(
+ (
+ {
+ children,
+ className,
+ external,
+ focusStyle,
+ focusVisibleStyle,
+ hoverStyle,
+ role,
+ ...restProps
+ }: PropsWithChildren,
+ ref: ForwardedRef,
+ ) => {
+ let props = restProps;
+
+ if (role === 'button') {
+ // When this link is announced as button by accessibility tools,
+ // it should also behave like a button. Links are not activated
+ // using `Space`, so we need to implement that behaviour here
+ // to reach parity with the `button` element.
+ props = {
+ ...restProps,
+ onKeyDown,
+ };
+ }
+
+ return (
+
+ {children}
+
+ );
+ },
+);
+
+ButtonLink.displayName = 'utrecht-button-link';
diff --git a/packages/component-library-react/src/index.ts b/packages/component-library-react/src/index.ts
index db232c125c..3f74b76027 100644
--- a/packages/component-library-react/src/index.ts
+++ b/packages/component-library-react/src/index.ts
@@ -6,6 +6,7 @@
export { Article } from './Article';
export { Backdrop } from './Backdrop';
export { Button } from './Button';
+export { ButtonLink } from './ButtonLink';
export { Checkbox } from './Checkbox';
export { Document } from './Document';
export { Fieldset } from './Fieldset';