ComponentsMisc
Link
Takes the reader to another page or place: a text link in the primary colour, underlined on hover.
Import
import { Link } from "@booleanpress/ui/link"Usage
Use a link to go somewhere, and a Button to do something. Link renders an <a>; pass asChild and your router's link to keep client-side navigation.
import { Link } from "@booleanpress/ui/link"
export function Help() {
return (
<Link href="https://example.com/docs" external>
SMTP setup guide
</Link>
)
}variant sets the colour: default (the primary colour), muted (slate, for footers and secondary places) or destructive. size sets 12, 14 or 16 px text; without it the link takes the size of the text around it, which is what a link inside a sentence wants. underline="hover" (default) underlines on hover; inside running text use underline="always", so the link does not rely on colour alone.
external opens the address in a new tab: it adds target="_blank", rel="noopener noreferrer" (kept beside your own rel values), an arrow after the text and a visually hidden "(opens in a new tab)". visited turns visited links purple. disabled sets aria-disabled, removes the address and ignores clicks; say near it why it is off.
Examples
Basic
A link in the primary colour, underlined on hover.
External
external adds the arrow, opens a new tab and tells screen readers so.
import { Link } from "@booleanpress/ui/link"
export default function LinkExternal() {
return (
<div className="flex flex-col items-start gap-2">
<Link href="https://example.com/docs/smtp" external size="default">
SMTP setup guide
</Link>
<Link href="https://example.com/status" external variant="muted" size="sm">
Service status
</Link>
</div>
)
}Muted
variant="muted" for a row of footer links.
import { Link } from "@booleanpress/ui/link"
export default function LinkMuted() {
return (
<footer className="flex flex-wrap gap-x-4 gap-y-1">
<Link href="#privacy" variant="muted" size="sm">
Privacy
</Link>
<Link href="#terms" variant="muted" size="sm">
Terms
</Link>
<Link href="#support" variant="muted" size="sm">
Support
</Link>
</footer>
)
}Destructive
variant="destructive" for a link that leads to removing something.
Sizes
sm, default and lg: 12, 14 and 16 px.
import { Link } from "@booleanpress/ui/link"
export default function LinkSizes() {
return (
<div className="flex flex-wrap items-baseline gap-4">
<Link href="#api-keys" size="sm">
API keys
</Link>
<Link href="#api-keys" size="default">
API keys
</Link>
<Link href="#api-keys" size="lg">
API keys
</Link>
</div>
)
}In a paragraph
Without a size the link takes the paragraph's; underline="always" marks it without colour.
import { Link } from "@booleanpress/ui/link"
export default function LinkInAParagraph() {
return (
<p className="max-w-md text-sm/normal">
A mailer stops sending once the provider rejects its key. Create a new key in the{" "}
<Link href="#api-keys" underline="always">
API keys
</Link>{" "}
settings, then read the{" "}
<Link href="https://example.com/docs/keys" underline="always" external>
provider’s key guide
</Link>{" "}
for the scopes it needs.
</p>
)
}As a router link
asChild puts the look on a router's link, which keeps its own navigation; the current one has aria-current.
import * as React from "react"
import { Link } from "@booleanpress/ui/link"
// A stand-in for a router's link component: it moves within the app without loading the page.
function RouterLink({
to,
onNavigate,
onClick,
children,
...props
}: React.ComponentProps<"a"> & { to: string; onNavigate: (to: string) => void }) {
return (
<a
{...props}
href={to}
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented) return
event.preventDefault()
onNavigate(to)
}}
>
{children}
</a>
)
}
export default function LinkAsRouterLink() {
const [path, setPath] = React.useState("/mailers")
return (
<div className="flex flex-col gap-2 text-sm">
<nav aria-label="Settings" className="flex gap-4">
{["/mailers", "/organisations", "/logs"].map((to) => (
<Link key={to} asChild size="default" variant={path === to ? "default" : "muted"}>
<RouterLink to={to} onNavigate={setPath} aria-current={path === to ? "page" : undefined}>
{to.slice(1, 2).toUpperCase() + to.slice(2)}
</RouterLink>
</Link>
))}
</nav>
<p className="text-muted-foreground">Current route: {path}</p>
</div>
)
}Disabled
disabled: no address, aria-disabled, 60% opacity, and a note saying why.
import { Link } from "@booleanpress/ui/link"
export default function LinkDisabled() {
return (
<div className="flex flex-col items-start gap-1">
<Link href="#export" disabled size="default">
Export the log
</Link>
<p className="text-xs text-muted-foreground">Export is available once the first delivery is logged.</p>
</div>
)
}Accessibility
- Semantics
- A native
<a href>, or the element passed withasChild. A disabled link keepsrole="link"without an address, witharia-disabled="true". - Labels
- Its text is its name: say where it goes ("SMTP setup guide", not "click here"). An external link's name ends with the provider's
opensInNewTabstring, "(opens in a new tab)"; the arrow is hidden from assistive technology. - Focus
- It is in the tab order; the focus outline shows on keyboard focus. A disabled
<a>has no address, so Tab skips it; withasChildit getstabindex="-1"instead. - Known limits
- Inside running text, the primary colour differs too little from the body text to mark a link by colour alone (WCAG 1.4.1): use
underline="always"there. - With
asChild,disabledcannot remove the child's own address or stop a router's click handler: it setsaria-disabled, takes the link out of the tab order and ignores the pointer. - Browsers show a visited colour only for addresses in the person's history, and only the colour can change; it has no example here because it depends on each reader's history.
- Inside running text, the primary colour differs too little from the body text to mark a link by colour alone (WCAG 1.4.1): use
Keyboard
| Key | Behaviour |
|---|---|
| Enter | Follows the link. |
API
Link
Renders a a and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render the child element instead (a router's link), with this link's look and behaviour merged onto it. |
disabled | boolean | false | Turns the link off: it sets aria-disabled, loses its address (with asChild, its place in the tab order) and
ignores the pointer. |
external | boolean | false | Opens in a new tab: adds target="_blank", rel="noopener noreferrer", an arrow after the text and a visually
hidden "(opens in a new tab)" from the provider's opensInNewTab string. |
size | "default" | "sm" | "lg" | null | sm 12/18, default 14/21, lg 16/24. Left out, the link takes the size of the text around it. | |
underline | "always" | "hover" | null | hover | hover (default) underlines on hover only; always keeps the underline, for links inside running text. |
variant | "default" | "destructive" | "muted" | null | default | The colour: default (the primary colour), muted or destructive. |
visited | boolean | null | false | Colours a link to a visited address purple. |
Also exported: linkVariants, the class names of Link's variants and sizes (cva), to give another element the same look.
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="link" (Link), and data-variant, data-size, data-external.
Provider strings: opensInNewTab (BooleanUIProvider's strings).
Theming
The colours are --primary, --muted-foreground and --destructive-strong, with the underline at 40% until hover; visited links take --help-active.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--destructive-strong | text, underline |
--foreground | text, underline |
--help-active | text |
--muted-foreground | text, underline |
--primary | text, underline |
--ring | outline |