Overview
The base Button (components/ui/Button.tsx) wraps shadcn/ui patterns with CVA for variants/sizes, Radix Slot for asChild, and our theme tokens for color, focus rings, and dark mode. Use this Button instead of raw <button> to keep styling and accessibility consistent across the app.
Variants & sizes
variant(visual style):default,highlight,outline,destructive,ghost,link,iconsize(spacing/shape):sm,md,lg,icon- Defaults:
variant="default",size="default". UseclassNameonly for small local tweaks; add/adjust CVA variants when patterns repeat.
import { Button } from '@/components/ui/Button';
export function Examples() {
return (
<div className="flex flex-wrap gap-3">
<Button>Default</Button>
<Button variant="highlight">Highlight</Button>
<Button variant="outline" size="lg">Outline</Button>
<Button variant="destructive">Delete</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link" asChild>
<a href="/terms">Link</a>
</Button>
<Button variant="icon" size="icon" aria-label="Like">
{/* your icon here */}
<svg width="16" height="16" aria-hidden="true" />
</Button>
</div>
);
}Composition
Render a different element (e.g., Next.js Link) while keeping Button styles and focus rings:
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
export function AsChildLink() {
return (
<Button asChild>
<Link href="/pricing">View pricing</Link>
</Button>
);
}Avoid nesting interactive elements (e.g., a <button> inside a <Link>). asChild solves this cleanly.
Focus & accessibility
The base button ships with tokenized focus rings and proper disabled behavior. Guidance:
- Use
type="button"for non-form buttons to avoid accidental submits. - Icon-only buttons must be labeled (use
aria-labelor visually hidden text). - Prefer
disabledover intercepting clicks; the component already dims and removes pointer events.
Loading & async states
Click-driven async (client): Use TransitionButton with useTransition() so the UI stays responsive and isPending drives the spinner/text.
'use client';
import { TransitionButton } from '@/components/TransitionButton';
export function AsyncClick() {
const [isPending, startTransition] = React.useTransition();
async function handle() {
startTransition(async () => {
// await your async work...
// e.g., await someServerAction();
});
}
return (
<TransitionButton
isPending={isPending}
pendingText="Working…"
onClick={handle}
>
Run task
</TransitionButton>
);
}Form submit (server action): Use SubmitButton. It reads useFormStatus() to switch to a pending state while the closest <form> action runs—no manual state or router calls needed.
'use client';
import { SubmitButton } from '@/components/SubmitButton';
export function FormExample() {
async function action(formData: FormData) {
'use server';
// handle server action
}
return (
<form action={action}>
{/* fields... */}
<SubmitButton pendingText="Submitting…">Submit</SubmitButton>
</form>
);
}Specialized buttons
- CheckoutButton / BuyButtonTemplate / SubscriptionButtonTemplate: high-level Lemon Squeezy flows; use
TransitionButton, show toasts on failure, redirect on success. - WaitlistButtonTemplate: opens
JoinWaitlistDialogfor consistent CTA flow. - SocialLoginButton: posts the chosen OAuth provider; uses
variant="icon"/size="icon"with provider icons. - SignOutButton: uses
useTransitionto callsignOutAction, shows a toast, refreshes state, and redirects.
When to choose what
- Normal clickable control →
Button - Link styled as a button →
Button asChild+Link - Spinner + pending text for async click →
TransitionButton - Submitting a form (Server Actions) →
SubmitButton - OAuth icon button →
SocialLoginButton(variant="icon"/size="icon")
Extending styles
If a new treatment repeats, add a variant/size in the CVA map (components/ui/Button.tsx). Keep names semantic (highlight, destructive, …). Use tokens and Tailwind utilities; avoid raw hex values.