Button

Triggers an action or event, with variants for every level of emphasis.

Installation

Terminal
pnpm dlx @dowel-ui/cli add button

Installs spinner as well, because this component imports it.

npm packages installed: class-variance-authority, radix-ui.

Accessibility

Keyboard activatable via Enter and Space. The loading state uses aria-disabled and aria-busy rather than the disabled attribute, so focus is never stranded mid-action. Use `size="icon"` only with an accessible name from aria-label or visually hidden text.

Props

Button

PropTypeDefault
loading

Shows a spinner and suppresses activation. Unlike disabled, a loading button stays focusable: taking focus away from the control a user just activated strands their keyboard position, and the state is transient by definition. Activation is blocked via aria-disabled plus a guarded click handler.

booleanfalse
press"scale" | "none""scale"
shape"default" | "pill" | "square""default"
size"sm" | "md" | "lg" | "icon" | "icon-sm""md"
variant"primary" | "secondary" | "outline" | "ghost" | "destructive" | "link" | "soft" | "gradient""primary"
asChild

Renders the child element as the button instead of a <button>, keeping every style and behaviour. Use it for links that should look like buttons.

booleanfalse

Plus every attribute of <button>.

Quality

10/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Used in

Whole screens assembled from this component. Installing one brings this and everything else it needs with it.

Source

This is exactly what dowel add button writes into your project, with imports rewritten to your own path alias.

ui/button.tsx
"use client";

// Motion from SmoothUI SmoothButton (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type { ComponentPropsWithRef, MouseEvent } from "react";

import { Spinner } from "@/components/spinner";
import { disabledStyles, focusRing, iconSlot } from "@/lib/styles";
import { cn } from "@/lib/utils";

const buttonVariants = cva(
  cn(
    "inline-flex shrink-0 items-center justify-center font-medium whitespace-nowrap select-none",
    "transition-[background-color,border-color,color,box-shadow,scale] duration-[var(--duration-fast)] ease-[var(--ease-out-quint)]",
    focusRing,
    disabledStyles,
    iconSlot,
  ),
  {
    variants: {
      variant: {
        primary:
          "bg-primary text-primary-foreground hover:bg-primary-hover active:bg-primary-active",
        secondary: "bg-secondary text-secondary-foreground hover:bg-accent active:bg-border",
        outline:
          "border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground active:bg-secondary",
        ghost:
          "text-foreground hover:bg-accent hover:text-accent-foreground active:bg-secondary",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/80",
        link: "text-primary underline-offset-4 hover:underline",
        /** A tint of the primary colour. Quieter than primary, warmer than ghost. */
        soft: cn(
          "bg-[color-mix(in_oklab,var(--color-primary)_12%,transparent)] text-primary",
          "hover:bg-[color-mix(in_oklab,var(--color-primary)_18%,transparent)]",
          "active:bg-[color-mix(in_oklab,var(--color-primary)_24%,transparent)]",
        ),
        /**
         * SmoothUI's "candy": primary running into its hover shade, with a
         * hairline highlight along the top edge. Every stop is a token, so it
         * follows the theme and the monochrome preset.
         */
        gradient: cn(
          "bg-linear-to-b from-primary to-primary-hover text-primary-foreground",
          "shadow-[inset_0_1px_0_color-mix(in_oklab,var(--color-primary-foreground)_25%,transparent),0_1px_2px_color-mix(in_oklab,var(--color-foreground)_20%,transparent)]",
          "hover:from-primary-hover hover:to-primary-hover",
          "active:from-primary-active active:to-primary-active",
        ),
      },
      size: {
        sm: "h-8 gap-1.5 rounded-md px-3 text-sm",
        md: "h-9 gap-2 rounded-md px-4 text-sm",
        lg: "h-10 gap-2 rounded-lg px-5 text-base",
        icon: "size-9 rounded-md",
        "icon-sm": "size-8 rounded-md",
      },
      /**
       * Corner treatment, independent of size. Declared after `size` so its
       * radius wins the merge. `default` keeps each size's own radius.
       */
      shape: {
        default: "",
        pill: "rounded-full",
        square: "rounded-none",
      },
      /**
       * Press feedback. `scale` shrinks the button to 97% while it is held —
       * a transform, so nothing around it moves, and it only exists when the
       * reader has not asked for reduced motion. `none` opts out.
       */
      press: {
        scale: "",
        none: "",
      },
    },
    compoundVariants: [
      // A link has no box, so box padding and height would only misalign it.
      { variant: "link", size: "sm", className: "h-auto px-0" },
      { variant: "link", size: "md", className: "h-auto px-0" },
      { variant: "link", size: "lg", className: "h-auto px-0" },
      // Text does not press; a link that shrinks under the finger reads as a
      // rendering glitch rather than feedback.
      {
        press: "scale",
        variant: [
          "primary",
          "secondary",
          "outline",
          "ghost",
          "destructive",
          "soft",
          "gradient",
        ],
        className: "motion-safe:active:scale-[0.97]",
      },
    ],
    defaultVariants: {
      variant: "primary",
      size: "md",
      shape: "default",
      press: "scale",
    },
  },
);

export interface ButtonProps
  extends ComponentPropsWithRef<"button">, VariantProps<typeof buttonVariants> {
  /**
   * Renders the child element as the button instead of a `<button>`, keeping
   * every style and behaviour. Use it for links that should look like buttons.
   */
  asChild?: boolean;
  /**
   * Shows a spinner and suppresses activation.
   *
   * Unlike `disabled`, a loading button stays focusable: taking focus away from
   * the control a user just activated strands their keyboard position, and the
   * state is transient by definition. Activation is blocked via `aria-disabled`
   * plus a guarded click handler.
   */
  loading?: boolean;
}

/** Triggers an action or event. */
export function Button({
  className,
  variant,
  size,
  shape,
  press,
  asChild = false,
  loading = false,
  disabled,
  onClick,
  children,
  ...props
}: ButtonProps) {
  const Comp = asChild ? Slot.Root : "button";

  function handleClick(event: MouseEvent<HTMLButtonElement>) {
    if (loading) {
      event.preventDefault();
      event.stopPropagation();
      return;
    }
    onClick?.(event);
  }

  return (
    <Comp
      className={cn(buttonVariants({ variant, size, shape, press }), className)}
      disabled={disabled}
      aria-disabled={loading || undefined}
      aria-busy={loading || undefined}
      data-loading={loading || undefined}
      onClick={handleClick}
      {...props}
    >
      {loading ? <Spinner size={size === "lg" ? "lg" : "sm"} /> : null}
      {/* Slottable marks which child Slot should merge into when asChild is set.
          Without it, adding the spinner would give Slot two children and break
          every `asChild` button the moment it started loading. */}
      <Slot.Slottable>{children}</Slot.Slottable>
    </Comp>
  );
}

export { buttonVariants };