The base action. Five variants, a loading state that keeps focus and width, a press response, and a hold-to-confirm mode for actions that should not fire by accident.
pnpm dlx shadcn@latest add @latheui/buttonFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add radix-ui motionAdd the Lathe items this component imports.
lib/motion.tscomponents/ui/hold-to-confirm.tsxCopy the source into your project.
"use client"
import * as React from "react"
import { motion } from "motion/react"
import { spring, useMotionSafe } from "@/lib/motion"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { HoldToConfirm } from "@/components/ui/hold-to-confirm"
const variants = {
primary:
"bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.16)] hover:bg-primary/88",
secondary: "bg-secondary text-secondary-foreground hover:bg-accent",
outline:
"border border-input bg-background text-foreground shadow-[0_1px_2px_rgb(0_0_0/0.05)] hover:bg-accent hover:text-accent-foreground",
// Pressed shows a stronger fill than hover, so a tap reads on touch screens with no hover.
ghost: "text-foreground hover:bg-accent hover:text-accent-foreground active:bg-foreground/12",
destructive:
"bg-destructive text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.16)] hover:bg-destructive/88 dark:text-background",
} as const
const sizes = {
sm: "h-8 gap-1.5 px-3 text-[13px]",
md: "h-9 px-4 text-sm",
lg: "h-11 px-5 text-[15px]",
icon: "size-9",
"icon-sm": "size-8",
} as const
export type ButtonProps = React.ComponentProps<"button"> & {
variant?: keyof typeof variants
size?: keyof typeof sizes
/** Shows a spinner and blocks clicks while keeping focus and width. */
loading?: boolean
/** Animate the intrinsic label width when content changes. */
morphWidth?: boolean
/** Render the child element (for example a link) with button styles. */
asChild?: boolean
/**
* Makes this a hold-to-confirm button: the action runs once it has been held for
* `holdDuration`, and letting go early cancels. Used instead of onClick.
*/
onHoldConfirm?: () => void
/** Milliseconds to hold, with onHoldConfirm. */
holdDuration?: number
/** Label shown briefly after a hold completes. */
confirmedLabel?: React.ReactNode
}
/** Heights for the hold mode, which lays out its own label and fill. */
const holdSizes = {
sm: "h-8 text-[13px]",
md: "h-9 text-sm",
lg: "h-11 text-[15px]",
icon: "h-9",
"icon-sm": "h-8",
} as const
function Button({
className,
variant = "primary",
size = "md",
loading = false,
morphWidth = true,
asChild = false,
disabled,
children,
onClick,
type,
onHoldConfirm,
holdDuration,
confirmedLabel,
...props
}: ButtonProps) {
const contentRef = React.useRef<HTMLSpanElement>(null)
const [contentWidth, setContentWidth] = React.useState<number>()
const { safe } = useMotionSafe()
React.useEffect(() => {
const node = contentRef.current
if (!node || !morphWidth || asChild) return
const observer = new ResizeObserver(([entry]) =>
setContentWidth(entry.contentRect.width)
)
observer.observe(node)
return () => observer.disconnect()
}, [morphWidth, asChild])
const classes = cn(
"relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md font-medium whitespace-nowrap select-none",
// Colors ease; the press goes down fast and comes back up on a soft overshoot.
"outline-none [transition:background-color_150ms_ease-out,border-color_150ms_ease-out,color_150ms_ease-out,box-shadow_150ms_ease-out,scale_420ms_cubic-bezier(0.3,1.5,0.6,1)]",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"active:scale-[0.96] active:[transition:scale_90ms_ease-out] motion-reduce:[transition:none] motion-reduce:active:scale-100",
"disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 aria-busy:cursor-progress aria-busy:active:scale-100",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants[variant],
sizes[size],
className
)
if (onHoldConfirm && !asChild) {
return (
<HoldToConfirm
onConfirm={onHoldConfirm}
duration={holdDuration}
confirmedLabel={confirmedLabel}
// The fill is the button's own color: red for destructive, the primary color otherwise.
variant={variant === "destructive" ? "destructive" : "primary"}
disabled={disabled || loading}
className={cn("rounded-md", holdSizes[size], className)}
{...props}
>
{children}
</HoldToConfirm>
)
}
if (asChild) {
return (
<Slot.Root data-slot="button" className={classes} {...props}>
{children}
</Slot.Root>
)
}
return (
<button
data-slot="button"
type={type ?? "button"}
className={classes}
disabled={disabled}
// Loading keeps the button focusable, so it is not `disabled`.
aria-busy={loading || undefined}
aria-disabled={loading || undefined}
onClick={loading ? (event) => event.preventDefault() : onClick}
{...props}
>
{loading && (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner />
</span>
)}
<motion.span
initial={false}
animate={{
width:
morphWidth && contentWidth !== undefined ? contentWidth : "auto",
}}
transition={safe(spring.snappy)}
className={cn(
"inline-flex items-center overflow-hidden",
loading && "opacity-0"
)}
>
<span
ref={contentRef}
className="inline-flex w-max shrink-0 items-center gap-2"
>
{children}
</span>
</motion.span>
</button>
)
}
function Spinner() {
return (
<svg
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className="size-4 animate-spin"
>
<circle
cx="8"
cy="8"
r="6.25"
stroke="currentColor"
strokeWidth="1.5"
opacity="0.25"
/>
<path
d="M14.25 8A6.25 6.25 0 0 0 8 1.75"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
)
}
export { Button }Depends on
Also accepts every native button attribute, including ref.
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "primary" | "secondary" | "outline" | "ghost" | "destructive" | "primary" | Visual weight. Use one primary button per view. |
| size | "sm" | "md" | "lg" | "icon" | "icon-sm" | "md" | Height and padding. The icon sizes are square. |
| morphWidth | boolean | true | Animate changes in the intrinsic label width; disabled by reduced motion. |
| loading | boolean | false | Shows a spinner, sets aria-busy, and ignores clicks. Ignored with asChild. |
| asChild | boolean | false | Merges the button styles and props onto the single child element. |
| onHoldConfirm | () => void | — | Makes it a hold-to-confirm button, used instead of onClick. Runs once the button has been held for holdDuration; releasing early cancels. Destructive buttons fill red, the rest fill with the primary color. |
| holdDuration | number | 1200 | Milliseconds to hold, with onHoldConfirm. |
| confirmedLabel | ReactNode | "Done" | Label shown briefly after a hold completes. |
| type | "button" | "submit" | "reset" | "button" | Defaults to button so it never submits a form by accident. |
className is merged last, so utilities win over the variant.
<Button className="rounded-full px-6">Get started</Button>Variants are a plain object at the top of the file. Add a key and it is typed automatically.
const variants = {
// ...
brand: "bg-chart-1 text-white hover:bg-chart-1/90",
} as const