Search docs... ⌘K Search Documentation Search across all documentation pages
Button
A clickable element that triggers actions — the most fundamental interactive component in any application.
Submit forms (login, signup, checkout)
Trigger navigation or route changes
Open modals, dropdowns, or drawers
Confirm destructive actions (delete, cancel)
Toggle states (like/unlike, follow/unfollow)
Copy text to clipboard
Download files or export data
"use client" ;
interface ButtonProps {
children : React . ReactNode ;
onClick ?: () => void ;
}
export function Button ({ children , onClick } : ButtonProps ) {
return (
< button
onClick = {onClick}
className = "rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 active:bg-blue-800"
>
{children}
</ button >
);
}
A minimal button with hover and active states. The onClick is optional so the button can also be used as a type="submit" inside forms without needing a handler.
"use client" ;
type Variant = "primary" | "secondary" | "danger" | "ghost" | "outline" ;
interface ButtonProps extends React . ButtonHTMLAttributes < HTMLButtonElement > {
variant ?: Variant ;
children
Uses a Record to map variant names to Tailwind classes. Extends ButtonHTMLAttributes so all native attributes (disabled, type, form, etc.) pass through automatically.
"use client" ;
type Size = "sm" | "md" | "lg" ;
interface ButtonProps extends React . ButtonHTMLAttributes < HTMLButtonElement > {
size ?: Size ;
children : React . ReactNode ;
Separating size from variant keeps the class logic manageable. Combine with the variant pattern for a full button system.
"use client" ;
interface ButtonProps extends React . ButtonHTMLAttributes < HTMLButtonElement > {
loading ?: boolean ;
children : React . ReactNode ;
}
export function Button ({ loading , children
The spinner SVG is inline so no icon library is needed. The button disables itself while loading to prevent double-clicks.
"use client" ;
interface IconButtonProps extends React . ButtonHTMLAttributes < HTMLButtonElement > {
label : string ;
children : React . ReactNode ;
}
export function IconButton ({ label , children
Icon-only buttons require aria-label for accessibility. The fixed h-10 w-10 keeps the click target consistent regardless of icon size.
"use client" ;
import Link from "next/link" ;
type ButtonAsLinkProps = {
href : string ;
children : React . ReactNode ;
className ?: string ;
};
type ButtonAsButtonProps
A discriminated union on href lets the same component render as either a <button> or a Next.js <Link>. TypeScript enforces that href and onClick don't mix incorrectly.
"use client" ;
import { useFormStatus } from "react-dom" ;
export function SubmitButton ({ children } : { children : React . ReactNode }) {
const { pending } = useFormStatus ();
return
useFormStatus must be called in a component that is a child of a <form>. It reads the pending state from the parent form's action, so you don't need to pass loading props manually.
"use client" ;
import { forwardRef } from "react" ;
import Link from "next/link" ;
type Variant = "primary" | "secondary" | "danger" | "ghost" | "outline" ;
type Size = "sm" |
Key aspects:
Polymorphic rendering — discriminated union on href switches between <button> and <Link> while maintaining correct TypeScript types for both.
forwardRef — allows parent components to attach refs for focus management, scroll-to, or animation libraries.
Focus-visible ring — focus-visible:ring-2 focus-visible:ring-offset-2 shows a focus ring only on keyboard navigation, not mouse clicks.
Loading replaces leftIcon — the spinner takes the leftIcon's slot, preventing layout shift during loading.
Link disabled pattern — links can't truly be disabled, so aria-disabled and tabIndex={-1} simulate it while pointer-events-none prevents clicks.
Class composition — array of class strings joined with filter(Boolean) keeps things readable and avoids extra spaces from conditional empty strings.
Missing type="button" on non-submit buttons — Buttons inside a <form> default to type="submit", which submits the form on click. Always add type="button" for buttons that aren't meant to submit.
Using <a> instead of <Link> for internal navigation — Plain <a> tags cause a full page reload. Use Next.js <Link> for client-side navigation.
Disabled buttons swallowing events — A disabled button doesn't fire onClick, which breaks tooltip or popover triggers. Wrap the button in a <span> if you need events on a disabled button.
Missing accessible name on icon buttons — A button with only an SVG icon has no text for screen readers. Always add aria-label to icon-only buttons.
Loading state without disabling — Showing a spinner but not disabling the button allows double-submissions. Always set disabled={true} or pointer-events-none when loading.
Inline arrow functions causing unnecessary re-renders — onClick={() => doSomething(id)} creates a new function each render. This rarely matters, but in a list of 100+ buttons, extract the handler or use useCallback.
:
React
.
ReactNode
;
}
const variantClasses : Record < Variant , string > = {
primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800" ,
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 active:bg-gray-300" ,
danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800" ,
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 active:bg-gray-200" ,
outline: "border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 active:bg-gray-100" ,
};
export function Button ({ variant = "primary" , children , className , ... rest } : ButtonProps ) {
return (
< button
className = { `rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none ${ variantClasses [ variant ] } ${ className ?? ""}` }
{ ... rest}
>
{children}
</ button >
);
}
}
const sizeClasses : Record < Size , string > = {
sm: "px-3 py-1.5 text-xs" ,
md: "px-4 py-2 text-sm" ,
lg: "px-6 py-3 text-base" ,
};
export function Button ({ size = "md" , children , ... rest } : ButtonProps ) {
return (
< button
className = { `rounded-lg bg-blue-600 font-medium text-white hover:bg-blue-700 ${ sizeClasses [ size ] }` }
{ ... rest}
>
{children}
</ button >
);
}
,
disabled
,
...
rest
}
:
ButtonProps
) {
return (
< button
disabled = {loading || disabled}
className = "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none"
{ ... rest}
>
{loading && (
< svg className = "h-4 w-4 animate-spin" viewBox = "0 0 24 24" fill = "none" >
< circle className = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" strokeWidth = "4" />
< path className = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</ svg >
)}
{children}
</ button >
);
}
,
...
rest
}
:
IconButtonProps
) {
return (
< button
aria-label = {label}
className = "inline-flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 hover:bg-gray-100 active:bg-gray-200"
{ ... rest}
>
{children}
</ button >
);
}
// Usage
< IconButton label = "Close menu" onClick = {onClose}>
< svg className = "h-5 w-5" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" >
< path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M6 18L18 6M6 6l12 12" />
</ svg >
</ IconButton >
=
React
.
ButtonHTMLAttributes
<
HTMLButtonElement
>
&
{
href ?: never ;
children : React . ReactNode ;
};
type ButtonProps = ButtonAsLinkProps | ButtonAsButtonProps ;
export function Button ( props : ButtonProps ) {
const base = "inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700" ;
if ( "href" in props && props.href) {
const { href , children , className } = props;
return (
< Link href = {href} className = { `${ base } ${ className ?? ""}` }>
{children}
</ Link >
);
}
const { children , className , ... rest } = props as ButtonAsButtonProps ;
return (
< button className = { `${ base } ${ className ?? ""}` } { ... rest}>
{children}
</ button >
);
}
(
< button
type = "submit"
disabled = {pending}
className = "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{pending && (
< svg className = "h-4 w-4 animate-spin" viewBox = "0 0 24 24" fill = "none" >
< circle className = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" strokeWidth = "4" />
< path className = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</ svg >
)}
{children}
</ button >
);
}
"md"
|
"lg"
|
"icon"
;
interface BaseProps {
variant ?: Variant ;
size ?: Size ;
loading ?: boolean ;
fullWidth ?: boolean ;
leftIcon ?: React . ReactNode ;
rightIcon ?: React . ReactNode ;
children ?: React . ReactNode ;
}
type ButtonAsButton = BaseProps &
Omit < React . ButtonHTMLAttributes < HTMLButtonElement >, keyof BaseProps > & {
href ?: never ;
};
type ButtonAsLink = BaseProps &
Omit < React . ComponentPropsWithoutRef < typeof Link>, keyof BaseProps > & {
href : string ;
disabled ?: boolean ;
};
type ButtonProps = ButtonAsButton | ButtonAsLink ;
const variantClasses : Record < Variant , string > = {
primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800 focus-visible:ring-blue-500" ,
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring-gray-400" ,
danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800 focus-visible:ring-red-500" ,
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 active:bg-gray-200 focus-visible:ring-gray-400" ,
outline: "border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 focus-visible:ring-gray-400" ,
};
const sizeClasses : Record < Size , string > = {
sm: "h-8 px-3 text-xs gap-1.5" ,
md: "h-10 px-4 text-sm gap-2" ,
lg: "h-12 px-6 text-base gap-2.5" ,
icon: "h-10 w-10 p-0" ,
};
function Spinner () {
return (
< svg className = "h-4 w-4 animate-spin" viewBox = "0 0 24 24" fill = "none" aria-hidden = "true" >
< circle className = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" strokeWidth = "4" />
< path className = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</ svg >
);
}
export const Button = forwardRef < HTMLButtonElement | HTMLAnchorElement , ButtonProps >(
function Button ( props , ref ) {
const {
variant = "primary" ,
size = "md" ,
loading = false ,
fullWidth = false ,
leftIcon ,
rightIcon ,
children ,
className ,
disabled ,
... rest
} = props;
const classes = [
"inline-flex items-center justify-center rounded-lg font-medium transition-colors" ,
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2" ,
"disabled:opacity-50 disabled:pointer-events-none" ,
variantClasses[variant],
sizeClasses[size],
fullWidth ? "w-full" : "" ,
loading ? "pointer-events-none" : "" ,
className ?? "" ,
]
. filter (Boolean)
. join ( " " );
const content = (
<>
{loading ? < Spinner /> : leftIcon}
{children}
{ ! loading && rightIcon}
</>
);
if ( "href" in rest && rest.href) {
const { href , ... linkRest } = rest as ButtonAsLink ;
return (
< Link
ref = {ref as React . Ref < HTMLAnchorElement >}
href = {href}
className = {classes}
aria-disabled = {disabled || loading}
tabIndex = {disabled || loading ? - 1 : undefined }
{ ... linkRest}
>
{content}
</ Link >
);
}
return (
< button
ref = {ref as React . Ref < HTMLButtonElement >}
disabled = {disabled || loading}
className = {classes}
{ ... (rest as Omit < ButtonAsButton , keyof BaseProps >)}
>
{content}
</ button >
);
}
);