Button
Five variants, three sizes, a press that gives, and a loading state that keeps the width.
- cva
- active:scale
- aria-busy
- CSS-only transitions
“Save changes” keeps its width while loading.
Install
npx shadcn@latest add https://tcosta.dev/r/button.jsonOr copy the files below into components/button/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need class-variance-authority.
Source
button.tsx
import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps, MouseEvent } from 'react';
import { cn } from '@/lib/utils';
import { buttonVariants } from './button-variants';
import { Spinner } from './spinner';
export type ButtonProps = ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
/** Shows a spinner in place of the content, keeping the button's width. Stays focusable. */
loading?: boolean;
};
export function Button({
variant,
size,
loading = false,
type = 'button',
className,
onClick,
children,
...props
}: ButtonProps) {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (loading) {
event.preventDefault();
return;
}
onClick?.(event);
};
return (
<button
type={type}
aria-busy={loading || undefined}
onClick={handleClick}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
>
{/* The content stays in the layout while loading, so the width never jumps. */}
<span
className={cn(
'inline-flex items-center gap-[inherit] transition-[opacity,filter] duration-150 motion-reduce:transition-none',
loading && 'opacity-0 blur-[2px]',
)}
>
{children}
</span>
<span
className={cn(
'absolute inset-0 flex items-center justify-center transition-[opacity,scale] duration-150 motion-reduce:transition-none',
loading ? 'scale-100 opacity-100' : 'scale-50 opacity-0',
)}
>
<Spinner />
</span>
</button>
);
}
button-variants.ts
import { cva } from 'class-variance-authority';
export const buttonVariants = cva(
[
'relative inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md text-sm font-medium whitespace-nowrap outline-none select-none',
'transition-[color,background-color,border-color,box-shadow,scale] duration-150 active:scale-[0.97]',
'focus-visible:ring-[3px] focus-visible:ring-ring/50',
'disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress',
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
],
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20',
},
size: {
sm: 'h-8 gap-1.5 px-3 text-xs',
md: 'h-9 gap-2 px-4',
lg: 'h-10 gap-2 px-5',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
},
);
spinner.tsx
export function Spinner() {
return (
<svg viewBox="0 0 24 24" fill="none" className="size-4 animate-spin" aria-hidden>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
index.ts
export { Button, type ButtonProps } from './button';
export { buttonVariants } from './button-variants';