Button
Triggers an action or event, with variants for every level of emphasis.
Installation
pnpm dlx @dowel-ui/cli add buttonInstalls 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
| Prop | Type | Default |
|---|---|---|
loadingShows a spinner and suppresses activation. Unlike | boolean | false |
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" |
asChildRenders the child element as the button instead of a | boolean | false |
Plus every attribute of <button>.
Quality
10/10 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — passes
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — passes
- No fixed widths — passes
Used in
Whole screens assembled from this component. Installing one brings this and everything else it needs with it.
- Admin — users
- Admin dashboard
- Agent console
- AI Chat
- AI workspace
- Analytics
- Billing
- Command center
- CRM
- CTA banner
- CTA centered
- CTA split image
- Footer Mega
- Footer Newsletter
- Forgot password
- Hero grid
- Hero minimal
- Hero perspective grid
- Hero product
- Hero split image
- Hero spotlight
- Login
- Logo links marquee
- Logo marquee
- Onboarding
- Pricing
- Pricing Single Plan
- Pricing Three Tier
- Pricing Two Tier
- Settings
- Sign up
- Team carousel
- Testimonial Rotator
Source
This is exactly what dowel add button writes into your project, with imports rewritten to your own path alias.
"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 };