{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "button", "title": "Button", "description": "A button component with variants and sizes.", "dependencies": [ "@base-ui/react", "class-variance-authority", "clsx", "tailwind-merge" ], "registryDependencies": [ "https://ui.digital.nsw.gov.au/registry/r/theme.json", "https://ui.digital.nsw.gov.au/registry/r/link.json", "https://ui.digital.nsw.gov.au/registry/r/spinner.json" ], "files": [ { "path": "src/components/button.tsx", "content": "'use client'\n\nimport { Button as ButtonPrimitive } from '@base-ui/react/button'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport clsx from 'clsx'\nimport React from 'react'\n\nimport { cn } from '@/lib/utils'\n\nimport { Link } from '@/components/link'\nimport { Spinner } from '@/components/spinner'\n\nconst styles = {\n base: [\n // Base\n 'relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-sm border text-base/7 font-bold motion-safe:transition-all',\n // Height floor, published per size step as `--btn-h` (see `styles.size`).\n // A floor rather than a fixed height so a wrapped label can still grow.\n 'min-h-(--btn-h)',\n // Border width, so the size steps can subtract it from their padding and\n // keep the outer box identical across variants. `outline` and `surface`\n // draw a 2px border and raise this to match; everything else stays 1px.\n // Without it the steps subtracted a hard-coded 1px and those two variants\n // came out 2px taller than `solid` at every step and breakpoint.\n '[--btn-border-w:1px]',\n // Focus — deliberately `focus:` (paints on pointer clicks too), unlike\n // Link/Input which use `focus-visible:`. Buttons give click feedback with\n // the ring; links only ring for keyboard/AT focus. Do not \"unify\" this.\n // The ring is offset 2px, so it lands on the *page* rather than on the\n // button. `--btn-bg` is the ink (see the note above `styles.colors`), which\n // flips for dark mode, so the ring follows the theme for every variant —\n // including `solid`, whose ring used to be its own fill colour and so went\n // near-invisible on a dark surface.\n 'focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-(--btn-bg)',\n // Disabled\n 'data-disabled:opacity-50 data-disabled:pointer-events-none',\n // State overlays, derived from the ink rather than restated per colour.\n // Deriving them means they follow `--btn-bg` wherever it comes from: the\n // colour token, its dark-mode counterpart, or a surface that re-points the\n // ink at its own (`[--btn-bg:var(--footer-ink)]` in footer.tsx and\n // footer-cta.tsx). `solid` overrides both with its white/black pair at\n // `data-[variant=solid]:` — an attribute selector, so it outranks these\n // regardless of source order. `oklab` matches the interpolation space\n // Tailwind's own `/10` alpha modifier emits, so the painted colour is\n // unchanged from when each colour restated these itself.\n '[--btn-hover-overlay:color-mix(in_oklab,var(--btn-bg)_10%,transparent)]',\n '[--btn-active-overlay:color-mix(in_oklab,var(--btn-bg)_20%,transparent)]',\n // Glyph size beside a label: one value for every step and breakpoint.\n // Every step sets `text-base/7`, so the label is 16px whatever the step —\n // only the padding changes. An icon next to it has to match the *text*,\n // not the box, so scaling it per step pairs a growing glyph with type that\n // never moves: at `lg` a 28px icon sat beside a 16px word, two and a half\n // times its cap height. 24px is 1.5x the label at every step, and the same\n // number `size='icon'` uses. `iconOnly` overrides this per step in\n // `compoundVariants` — with no label to match, it scales with its box.\n '[--btn-icon-size:--spacing(6)]',\n // Icon. No vertical margin: the glyph is `self-center` in a baseline-aligned\n // row, so margin buys it nothing optically and only inflates the flex line.\n // It was inert at `sm`/`default` (glyph + margin stayed inside the 1.75rem\n // line box) but pushed `lg` 2px past its own height below `sm:`, so a `lg`\n // button measured 70px with an icon and 68px without.\n '*:data-[slot=icon]:-mx-0.25 *:data-[slot=icon]:size-(--btn-icon-size) *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) forced-colors:[--btn-icon:ButtonText] forced-colors:hover:[--btn-icon:ButtonText]',\n ],\n solid: [\n // Text color\n 'text-(--btn-text)',\n // Optical border, implemented as the button background to avoid corner artifacts\n 'border-transparent bg-(--btn-border)',\n // Dark mode: border is rendered on `after` so background is set to button background\n 'dark:bg-(--btn-fill)',\n // Button background, implemented as foreground layer to stack on top of pseudo-border layer\n 'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-sm)-1px)] before:bg-(--btn-fill)',\n // Drop shadow, applied to the inset `before` layer so it blends with the border\n 'before:shadow-sm',\n // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo\n 'dark:before:hidden',\n // Dark mode: Subtle white outline is applied using a border\n 'dark:border-white/5',\n // Shim/overlay, inset to match button foreground and used for hover state + highlight shadow\n 'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-sm)-1px)]',\n // State overlays\n 'hover:after:bg-(--btn-hover-overlay) active:after:bg-(--btn-active-overlay)',\n // Dark mode: `after` layer expands to cover entire button\n 'dark:after:-inset-px dark:after:rounded-sm',\n // Disabled\n 'data-disabled:before:shadow-none data-disabled:after:shadow-none',\n ],\n soft: [\n // Text color\n 'text-(--btn-bg)',\n // Optical border, implemented as the button background to avoid corner artifacts\n 'border-transparent bg-(--btn-bg)/10',\n // Dark mode: border is rendered on `after` so background is set to button background\n 'dark:bg-(--btn-bg)/20',\n // Button background, implemented as foreground layer to stack on top of pseudo-border layer\n 'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-sm)-1px)] before:bg-(--btn-bg)/10',\n // Drop shadow, applied to the inset `before` layer so it blends with the border\n 'before:shadow-sm',\n // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo\n 'dark:before:bg-white/5',\n // Dark mode: Subtle white outline is applied using a border\n 'dark:border-white/5',\n // Shim/overlay, inset to match button foreground and used for hover state + highlight shadow\n 'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-sm)-1px)]',\n // State overlays\n 'hover:after:bg-(--btn-hover-overlay) active:after:bg-(--btn-active-overlay)',\n // Dark mode: `after` layer expands to cover entire button\n 'dark:after:-inset-px dark:after:rounded-sm',\n // Disabled\n 'data-disabled:before:shadow-none data-disabled:after:shadow-none',\n ],\n surface: [\n // Text color\n 'text-(--btn-bg)',\n // Optical border, implemented as the button background to avoid corner artifacts\n 'border-(--btn-bg)/50 border-2 bg-(--btn-bg)/5 [--btn-border-w:2px]',\n // Dark mode: border is rendered on `after` so background is set to button background\n 'dark:bg-(--btn-bg)/30',\n // Button background, implemented as foreground layer to stack on top of pseudo-border layer\n 'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-sm)-1px)] before:bg-(--btn-bg)/5',\n // Drop shadow, applied to the inset `before` layer so it blends with the border\n 'before:shadow-sm',\n // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo\n 'dark:before:hidden',\n // Dark mode: Subtle white outline is applied using a border\n 'dark:border-(--btn-bg)/50',\n // Shim/overlay, inset to match button foreground and used for hover state + highlight shadow\n 'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-sm)-1px)]',\n // Border color on hover\n 'active:border-(--btn-bg) hover:border-(--btn-bg)',\n // State overlays\n 'hover:after:bg-(--btn-hover-overlay) active:after:bg-(--btn-active-overlay)',\n // Dark mode: `after` layer expands to cover entire button\n 'dark:after:-inset-px dark:after:rounded-sm',\n // Disabled\n 'data-disabled:before:shadow-none data-disabled:after:shadow-none',\n ],\n outline: [\n // Text color\n 'border-(--btn-bg) text-(--btn-bg) border-2 [--btn-border-w:2px]',\n // Optical border, implemented as the button background to avoid corner artifacts\n 'bg-transparent',\n // Button background, implemented as foreground layer to stack on top of pseudo-border layer\n 'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-sm)-1px)] before:bg-transparent',\n // Drop shadow, applied to the inset `before` layer so it blends with the border\n 'before:shadow-sm',\n // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo\n 'dark:before:hidden',\n // Dark mode: Subtle white outline is applied using a border\n 'dark:border-(--btn-bg)',\n // Shim/overlay, inset to match button foreground and used for hover state + highlight shadow\n 'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-sm)-1px)]',\n // State overlays\n 'hover:after:bg-(--btn-hover-overlay) active:after:bg-(--btn-active-overlay)',\n // Dark mode: `after` layer expands to cover entire button\n 'dark:after:-inset-px dark:after:rounded-sm',\n // Disabled\n 'data-disabled:before:shadow-none data-disabled:after:shadow-none',\n ],\n ghost: [\n // Text color\n 'text-(--btn-bg)',\n // Optical border, implemented as the button background to avoid corner artifacts\n 'border-transparent bg-(--btn-transparent)',\n // Button background, implemented as foreground layer to stack on top of pseudo-border layer\n 'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-sm)-1px)] before:bg-(--btn-transparent)',\n // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo\n 'dark:before:hidden',\n // Dark mode: Subtle white outline is applied using a border\n 'dark:border-white/5',\n // Shim/overlay, inset to match button foreground and used for hover state + highlight shadow\n 'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-sm)-1px)]',\n // State overlays\n 'hover:after:bg-(--btn-hover-overlay) active:after:bg-(--btn-active-overlay)',\n // Dark mode: `after` layer expands to cover entire button\n 'dark:after:-inset-px dark:after:rounded-sm',\n // Disabled\n 'data-disabled:before:shadow-none data-disabled:after:shadow-none',\n ],\n link: [\n // Text color — inherits from color token, no background or border\n 'text-(--btn-bg) border-transparent bg-transparent',\n // Halo tokens derive from the colour token via color-mix, mirroring Link\n '[--link-halo:color-mix(in_oklch,var(--btn-bg)_10%,transparent)]',\n '[--link-halo-active:color-mix(in_oklch,var(--btn-bg)_18%,transparent)]',\n // Resting underline, thickened on interaction — matching Link\n 'underline underline-offset-4 hover:decoration-2 active:decoration-2',\n // Hover / active halos match the GOV.UK-style halo on Link (see link.tsx\n // styledBase): the fill is painted on the `after` layer — consistent with\n // the other variants' hover overlays — and the box-shadow extends it 2px\n // above and 4px below the line box\n 'after:absolute after:inset-0 after:-z-10',\n 'hover:after:bg-(--link-halo) active:after:bg-(--link-halo-active)',\n 'hover:shadow-[0_-2px_0_var(--link-halo),0_4px_0_var(--link-halo)]',\n 'active:shadow-[0_-2px_0_var(--link-halo-active),0_4px_0_var(--link-halo-active)]',\n ],\n // Two variables, deliberately separate.\n //\n // `--btn-fill` is the block of colour `solid` paints behind `--btn-text`.\n // `--btn-bg` is the *ink*: the glyph and label on every non-solid variant\n // (`text-(--btn-bg)`), the border on `outline`/`surface`, the tint on\n // `soft`/`surface`, the `link` halos, the state overlays derived in\n // `styles.base`, and the focus ring. They start life the same colour, and\n // only part company in dark mode.\n //\n // They used to be one variable, which made the ink un-flippable. The values\n // here are masterbrand palette steps, and palette steps are theme-invariant —\n // `styles.css` re-declares only the *semantic* tokens under\n // `[data-theme=dark], .dark`. Design-system surfaces do flip, though\n // (`Header color='white'` is `bg-white dark:bg-grey-900`), so a non-solid\n // button kept its light-mode ink on a dark surface: `primary-800` on\n // `grey-900` measures 1.32:1, far under the 3:1 WCAG 1.4.11 asks of a UI\n // component, with the overlays and focus ring unpainted for the same reason.\n // Splitting the fill off means `--btn-bg` can flip on its own without\n // repainting `solid`, so the dark counterparts below need no variant scoping\n // and stay at single-class specificity — which is what keeps them overridable\n // by a surface that imposes its own ink (`[--btn-bg:var(--footer-ink)]` in\n // footer.tsx and footer-cta.tsx). Raising their specificity would silently\n // win against those, so keep any future dark value a bare `dark:` utility.\n //\n // `-200` is the package's established ink-on-dark (`--link-color`,\n // `--main-nav-panel-ink`) and clears 10:1 against every dark surface token.\n // `white` and `secondary` are already light inks, so they have no counterpart.\n //\n // Dark values use the raw NSW tokens for the tree-shaking reason spelled out\n // above `danger` below.\n colors: {\n grey: [\n // Base\n '[--btn-fill:var(--color-grey-600)] [--btn-bg:var(--color-grey-600)] [--btn-border:var(--color-grey-600)]/90 [--btn-text:white]',\n // Dark mode: ink\n 'dark:[--btn-bg:var(--grey-200)]',\n // State: Hover\n 'data-[variant=solid]:[--btn-hover-overlay:var(--color-white)]/10',\n // State: Active\n 'data-[variant=solid]:[--btn-active-overlay:var(--color-black)]/15',\n ],\n white: [\n // No dark counterpart: white is already the maximum-contrast ink on any\n // dark surface. Surface-conditional in the same way as `secondary` — on a\n // light surface a non-solid `white` button is white on white. See the\n // note on the `color` prop.\n // Base\n '[--btn-fill:var(--color-white)] [--btn-bg:var(--color-white)] [--btn-border:var(--color-white)]/90 [--btn-text:var(--color-grey-800)]',\n // State: Hover\n 'data-[variant=solid]:[--btn-hover-overlay:var(--color-black)]/10',\n // State: Active\n 'data-[variant=solid]:[--btn-active-overlay:var(--color-black)]/15',\n ],\n primary: [\n // Base\n '[--btn-fill:var(--color-primary-800)] [--btn-bg:var(--color-primary-800)] [--btn-border:var(--color-primary-800)]/90 [--btn-text:white]',\n // Dark mode: ink\n 'dark:[--btn-bg:var(--primary-200)]',\n // State: Hover\n 'data-[variant=solid]:[--btn-hover-overlay:var(--color-white)]/10',\n // State: Active\n 'data-[variant=solid]:[--btn-active-overlay:var(--color-black)]/15',\n ],\n secondary: [\n // No dark counterpart: `primary-200` already *is* the ink the other\n // colours flip to. Like `white`, this token is surface-conditional — it\n // is meant for dark surfaces, where it lands at 15.40:1. As a non-solid\n // ink on a light one it measures ~1.2:1, so that pairing is misuse\n // rather than a defect to fix here. See the note on the `color` prop.\n // Base\n '[--btn-fill:var(--color-primary-200)] [--btn-bg:var(--color-primary-200)] [--btn-border:var(--color-primary-200)]/90 [--btn-text:var(--color-primary-800)]',\n // State: Hover\n 'data-[variant=solid]:[--btn-hover-overlay:var(--color-white)]/15',\n // State: Active\n 'data-[variant=solid]:[--btn-active-overlay:var(--color-primary-800)]/10',\n ],\n tertiary: [\n // Base\n '[--btn-fill:var(--color-primary-600)] [--btn-bg:var(--color-primary-600)] [--btn-border:var(--color-primary-600)]/90 [--btn-text:white]',\n // Dark mode: ink\n 'dark:[--btn-bg:var(--primary-200)]',\n // State: Hover\n 'data-[variant=solid]:[--btn-hover-overlay:var(--color-white)]/10',\n // State: Active\n 'data-[variant=solid]:[--btn-active-overlay:var(--color-black)]/15',\n ],\n accent: [\n // Base\n '[--btn-fill:var(--color-accent-600)] [--btn-bg:var(--color-accent-600)] [--btn-border:var(--color-accent-600)]/90 [--btn-text:white]',\n // Dark mode: ink\n 'dark:[--btn-bg:var(--accent-200)]',\n // State: Hover\n 'data-[variant=solid]:[--btn-hover-overlay:var(--color-white)]/10',\n // State: Active\n 'data-[variant=solid]:[--btn-active-overlay:var(--color-black)]/15',\n ],\n // Semantic colours use the raw NSW token names (--danger-600, --success-600,\n // --warning-600) rather than Tailwind's `--color-*` bridge aliases. The\n // bridge aliases are tree-shaken by Tailwind v4 unless a matching utility\n // class is detected in scanned source, and arbitrary-property usages like\n // `[--btn-bg:var(--color-success-600)]` do NOT count as a usage signal.\n // The raw tokens are defined on :root by @nswds/tokens (a plain CSS import,\n // not a Tailwind theme), so they always resolve.\n danger: [\n // Base\n '[--btn-fill:var(--danger-600)] [--btn-bg:var(--danger-600)] [--btn-border:var(--danger-600)]/90 [--btn-text:white]',\n // Dark mode: ink\n 'dark:[--btn-bg:var(--danger-200)]',\n // State: Hover\n 'data-[variant=solid]:[--btn-hover-overlay:var(--color-white)]/10',\n // State: Active\n 'data-[variant=solid]:[--btn-active-overlay:var(--color-black)]/15',\n ],\n success: [\n // Base\n '[--btn-fill:var(--success-600)] [--btn-bg:var(--success-600)] [--btn-border:var(--success-600)]/90 [--btn-text:white]',\n // Dark mode: ink\n 'dark:[--btn-bg:var(--success-200)]',\n // State: Hover\n 'data-[variant=solid]:[--btn-hover-overlay:var(--color-white)]/10',\n // State: Active\n 'data-[variant=solid]:[--btn-active-overlay:var(--color-black)]/15',\n ],\n warning: [\n // Base\n '[--btn-fill:var(--warning-600)] [--btn-bg:var(--warning-600)] [--btn-border:var(--warning-600)]/90 [--btn-text:white]',\n // Dark mode: ink\n 'dark:[--btn-bg:var(--warning-200)]',\n // State: Hover\n 'data-[variant=solid]:[--btn-hover-overlay:var(--color-white)]/10',\n // State: Active\n 'data-[variant=solid]:[--btn-active-overlay:var(--color-black)]/15',\n ],\n },\n // Each step publishes its own height as `--btn-h`, built from the same two\n // numbers that produce its box: one line box (`text-base/7` — Tailwind's\n // `/7` is `--spacing(7)`, so this is the same token, not a matching literal)\n // plus twice the vertical padding. `styles.base` pins it with `min-h`, and\n // `styles.iconOnly` reads it as both dimensions of the square — so an\n // icon-only button is exactly as tall as the text button beside it by\n // construction, and stays that way if a step is ever retuned.\n //\n // The border cancels out of that sum: padding is `--spacing(n) -\n // --btn-border-w` and the border adds `--btn-border-w` back, so every\n // variant lands on `--btn-h` whether it draws a 1px or 2px edge, and the\n // outer geometry stays put while the label shifts 1px to make room.\n //\n // The steps set padding and height only. Glyph size lives on the base (one\n // value beside a label) and in `compoundVariants` (per step when icon-only).\n size: {\n default:\n 'px-[calc(--spacing(6)-var(--btn-border-w))] py-[calc(--spacing(4)-var(--btn-border-w))] sm:px-[calc(--spacing(5.5)-var(--btn-border-w))] sm:py-[calc(--spacing(3)-var(--btn-border-w))] [--btn-h:calc(--spacing(7)+--spacing(4)*2)] sm:[--btn-h:calc(--spacing(7)+--spacing(3)*2)]',\n sm: 'px-[calc(--spacing(5)-var(--btn-border-w))] py-[calc(--spacing(3)-var(--btn-border-w))] sm:px-[calc(--spacing(4.5)-var(--btn-border-w))] sm:py-[calc(--spacing(2)-var(--btn-border-w))] [--btn-h:calc(--spacing(7)+--spacing(3)*2)] sm:[--btn-h:calc(--spacing(7)+--spacing(2)*2)]',\n lg: 'px-[calc(--spacing(7)-var(--btn-border-w))] py-[calc(--spacing(5)-var(--btn-border-w))] sm:px-[calc(--spacing(6.5)-var(--btn-border-w))] sm:py-[calc(--spacing(4)-var(--btn-border-w))] [--btn-h:calc(--spacing(7)+--spacing(5)*2)] sm:[--btn-h:calc(--spacing(7)+--spacing(4)*2)]',\n // A compact chrome square that deliberately sits below the text steps —\n // header actions, dialog close buttons, footer social links. For an icon\n // button that must line up with text buttons beside it, use `iconOnly`\n // with `sm`/`default`/`lg` instead. Its glyph is the base 24px.\n icon: 'w-10 h-10 flex-none [--btn-h:--spacing(10)]',\n },\n // Square the button at the current step and drop the horizontal padding, so\n // the scale step and \"is this icon-only\" are independent axes. The glyph\n // needs no adjustment — the step already sizes it off the ladder.\n iconOnly: 'size-(--btn-h) flex-none p-0 sm:p-0',\n}\n\nconst buttonVariants = cva(styles.base, {\n variants: {\n variant: {\n solid: styles.solid,\n soft: styles.soft,\n surface: styles.surface,\n outline: styles.outline,\n ghost: styles.ghost,\n link: styles.link,\n },\n color: {\n white: styles.colors.white,\n grey: styles.colors.grey,\n primary: styles.colors.primary,\n secondary: styles.colors.secondary,\n tertiary: styles.colors.tertiary,\n accent: styles.colors.accent,\n danger: styles.colors.danger,\n success: styles.colors.success,\n warning: styles.colors.warning,\n },\n size: {\n default: styles.size.default,\n sm: styles.size.sm,\n lg: styles.size.lg,\n icon: styles.size.icon,\n },\n // Declared after `size` so its `p-0` reaches `cn()` downstream of the\n // step's `px-*`/`py-*` and wins the tailwind-merge conflict.\n iconOnly: {\n true: styles.iconOnly,\n false: '',\n },\n },\n compoundVariants: [\n // ── Icon-only glyph sizes ────────────────────────────────────────────\n //\n // With no label beside it the glyph has nothing to match, so here — and\n // only here — it scales with its box, off the icon ladder: 20 / 24 / 28 /\n // 32 / 36 / 40px, i.e. `--spacing(5)` through `--spacing(10)`. Every level\n // is a multiple of 4, so glyphs share the grid with the padding and\n // heights around them and land on whole device pixels at 1x. A discrete\n // ladder rather than a proportion of the box, because a glyph is only\n // crisp at the sizes it was drawn for — \"40% of the box\" would ask for\n // sizes no glyph exists at.\n //\n // One value per step, not per breakpoint: the square never gets smaller\n // on a wider viewport, so neither should what sits inside it.\n { size: 'sm', iconOnly: true, className: '[--btn-icon-size:--spacing(5)]' },\n { size: 'default', iconOnly: true, className: '[--btn-icon-size:--spacing(6)]' },\n { size: 'lg', iconOnly: true, className: '[--btn-icon-size:--spacing(7)]' },\n // The link variant reads as an inline Link despite the