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
,icon
size
(spacing/shape):sm
,md
,lg
,icon
- Defaults:
variant="default"
,size="default"
. UseclassName
only 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-label
or visually hidden text). - Prefer
disabled
over 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
JoinWaitlistDialog
for consistent CTA flow. - SocialLoginButton: posts the chosen OAuth provider; uses
variant="icon"
/size="icon"
with provider icons. - SignOutButton: uses
useTransition
to 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.