Button
Preview
Code
ts
import Button from '@/components/layout/Button';src/components/layout/Button.tsx
AI prompt
text
Build a button component in React + TypeScript + Tailwind CSS.
Four variants, three sizes, and a loading state.
## Look
- Base: `inline-flex items-center justify-center rounded-lg font-semibold transition-colors`, `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400`, `disabled:cursor-not-allowed disabled:opacity-50`.
- Variants:
- `primary`: `bg-indigo-600 text-white hover:bg-indigo-700`
- `secondary`: `bg-white text-slate-700 border border-slate-300 hover:bg-slate-50`, dark `bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700`
- `ghost`: `text-slate-600 hover:bg-slate-100`, dark `text-slate-300 hover:bg-slate-800`
- `danger`: `bg-rose-600 text-white hover:bg-rose-700` — for irreversible actions only, so it keeps its signal.
- Sizes: `sm` `px-2.5 py-1 text-[11px] gap-1`; `md` `px-3 py-2 text-xs gap-1.5`; `lg` `px-4 py-2.5 text-sm gap-2`.
- Keep variants and sizes as `as const` maps so adding one is one line.
## Behaviour
- `loading` shows a 14px lucide `Loader2` with `animate-spin` before the children AND disables the button. A spinner on a still-clickable control is how you get two submits.
- Forward the ref and pass every native `<button>` attribute through; `className` is appended last.
## API
`variant?: 'primary' | 'secondary' | 'ghost' | 'danger'` ('primary'), `size?: 'sm' | 'md' | 'lg'` ('md'), `loading?` (false), plus `ButtonHTMLAttributes`.
## Demo
"Save changes" in all four variants; a row of sm/md/lg; a "Submit" that goes loading for 1.2s when clicked; a disabled "Unavailable".
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { forwardRef } from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/cn';
const VARIANT = {
primary: 'bg-indigo-600 text-white hover:bg-indigo-700',
secondary:
'bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-700',
ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800',
danger: 'bg-rose-600 text-white hover:bg-rose-700',
} as const;
const SIZE = {
sm: 'px-2.5 py-1 text-[11px] gap-1',
md: 'px-3 py-2 text-xs gap-1.5',
lg: 'px-4 py-2.5 text-sm gap-2',
} as const;
/**
* `loading` also disables the button. A spinner on a still-clickable control is
* how you get two submits, and every caller that remembered the spinner but
* forgot `disabled` had that bug.
*/
const Button = forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: keyof typeof VARIANT;
size?: keyof typeof SIZE;
loading?: boolean;
}
>(function Button({ variant = 'primary', size = 'md', loading = false, className, children, disabled, ...rest }, ref) {
return (
<button
ref={ref}
disabled={disabled || loading}
className={cn(
'inline-flex items-center justify-center rounded-lg font-semibold transition-colors',
'disabled:cursor-not-allowed disabled:opacity-50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400',
VARIANT[variant],
SIZE[size],
className,
)}
{...rest}
>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />}
{children}
</button>
);
});
export default Button;
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | keyof typeof VARIANT | 'primary' | |
size | keyof typeof SIZE | 'md' | |
loading | boolean | false |
Also accepts every prop <button> takes — they are spread onto the root element.