{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "link", "title": "Link", "description": "A link primitive with a provider for app router link components.", "dependencies": [ "class-variance-authority", "clsx", "tailwind-merge" ], "registryDependencies": [ "https://ui.digital.nsw.gov.au/registry/r/theme.json" ], "files": [ { "path": "src/components/link.tsx", "content": "'use client'\n\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport React, { createContext, useContext } from 'react'\n\nimport { cn } from '@/lib/utils'\n\n// Shared visual base for all *styled* variants. Pulled out so the `unstyled`\n// variant can omit it entirely (otherwise it wouldn't actually be unstyled).\nconst styledBase = [\n // Typography + transition\n 'font-medium underline decoration-current underline-offset-4 motion-safe:transition-colors',\n // Text colour derives from --link-color; --link-halo and --link-halo-active\n // derive from --link-color via color-mix so each variant only sets the one\n // token and the hover/active halos follow automatically.\n 'text-(--link-color)',\n '[--link-halo:color-mix(in_oklch,var(--link-color)_10%,transparent)]',\n '[--link-halo-active:color-mix(in_oklch,var(--link-color)_18%,transparent)]',\n // Hover / active halos use box-shadow + background-color rather than\n // padding, so inline links sit flush against surrounding text. The\n // shadows extend the halo 2px above and 4px below the line box, matching\n // the GOV.UK Design System focus pattern.\n 'hover:bg-(--link-halo) hover:decoration-2',\n 'hover:shadow-[0_-2px_0_var(--link-halo),0_4px_0_var(--link-halo)]',\n // Wrapped lines each get their own halo\n '[box-decoration-break:clone] [-webkit-box-decoration-break:clone]',\n 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--link-color)',\n // Default sizing for icon children — without this, an unsized SVG inside\n // a Link collapses to 0×0 in flex layouts. `vertical-align: -0.15em`\n // aligns the icon's box to the text's x-height for inline-flow usage;\n // flex contexts ignore vertical-align and should set `items-baseline` (or\n // similar) on the Link itself. Consumers can override per-icon by passing\n // a `size-*` or `align-*` class on the icon itself.\n '*:data-[slot=icon]:inline-block *:data-[slot=icon]:size-[1em] *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:align-[-0.15em]',\n]\n\nconst linkVariants = cva('', {\n variants: {\n variant: {\n primary: [\n ...styledBase,\n // Link\n '[--link-color:var(--color-primary-800)] dark:[--link-color:var(--color-primary-200)]',\n // Visited — set the text colour directly. `:visited` is restricted by\n // browsers to a small set of paint-only properties (color, *-color,\n // fill, stroke) and ignores custom properties, so we can't shift\n // --link-color here. Setting `text-*` works, and the underline follows\n // because the base uses `decoration-current` (currentColor).\n 'visited:text-primary-600 dark:visited:text-primary-300',\n // Active — deeper halo via --link-halo-active\n 'active:bg-(--link-halo-active) active:decoration-2',\n 'active:shadow-[0_-2px_0_var(--link-halo-active),0_4px_0_var(--link-halo-active)]',\n ],\n secondary: [\n ...styledBase,\n '[--link-color:var(--color-primary-200)] dark:[--link-color:var(--color-primary-800)]',\n ],\n white: [\n ...styledBase,\n '[--link-color:var(--color-white)] dark:[--link-color:var(--color-grey-800)]',\n ],\n // Zero classes — the consumer (typically Button / BadgeButton) is\n // supplying its own complete visual treatment and doesn't want any\n // Link styling to compose on top. Intentionally omits the icon defaults\n // too, so the wrapping component has full control of icon sizing.\n unstyled: [],\n },\n },\n defaultVariants: {\n variant: 'primary',\n },\n})\n\ntype LinkComponent = React.ElementType\n\n/**\n * Object form of a link target, mirroring the `UrlObject` accepted by framework\n * link components such as next/link. Used for `Link`'s `href` when a custom\n * element/component is supplied via `as` / `LinkProvider`; a plain anchor still\n * takes a string.\n *\n * Fields are optional, matching next/link. Typing the *shape* — rather than the\n * previous bare `object` — rejects unrelated values like arrays and functions\n * (both carry a numeric `length`, which is incompatible with `length?: never`).\n */\ntype LinkUrlObject = {\n pathname?: string | null\n query?: string | Record | null\n hash?: string | null\n search?: string | null\n host?: string | null\n hostname?: string | null\n port?: string | number | null\n protocol?: string | null\n /** Brand that excludes arrays/functions (which have a numeric `length`). */\n length?: never\n}\n\ntype LinkProps = Omit, 'href'> &\n VariantProps & {\n as?: LinkComponent\n href: React.ComponentPropsWithoutRef<'a'>['href'] | LinkUrlObject\n ref?: React.Ref\n }\n\nconst LinkComponentContext = createContext(null)\n\nfunction LinkProvider({\n children,\n component,\n}: {\n children: React.ReactNode\n component: LinkComponent\n}) {\n return {children}\n}\n\n/**\n * Polymorphic anchor wrapper. Renders an `` by default, or any element\n * provided via the `as` prop, or via a `` context\n * (use the context to inject a framework Link like Next.js `next/link`).\n *\n * Hover / active / focus styling on consumers should use the native CSS\n * pseudo-class utilities (`hover:`, `active:`, `focus-visible:`, and their\n * `group-…` variants) — Tailwind v4 maps them to real CSS, no JS state\n * tracking required.\n */\n/**\n * Anchor-only HTML attributes. When `Link` is rendered as a non-anchor\n * intrinsic element (e.g. `as=\"button\"`), forwarding these produces invalid\n * markup like `