Button component for React
Five styles, three sizes, icons, loading, groups, icon buttons and neutral sign-in buttons.
One of 6 components in Buttons
Styles, sizes and states
The button is the most used component of any interface, and it sets the tone for everything else. This one comes in five styles, primary, secondary, outline, ghost and destructive, in three sizes, and it renders a real link when you pass href and a real button otherwise.
Styles and Sizes show the hierarchy at a glance. With icons places an icon before or after the label at the right gap. Loading keeps the width while it works, so the layout does not jump. Groups joins buttons into one row, Icon buttons are square and require an aria label, Neutral sign-in shows continue buttons for email and passkey without any foreign logos.
Give each view one primary button. Keep the size consistent within a row, mixing sizes reads as a mistake. On touch screens the kit sizes reach at least 44 pixels of tap height, so small buttons stay easy to hit.
Variants
Details
What Button brings along, read from the registry and the source.
- Category
- Buttons
- Area
- Elements
- Variants
- 7
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/button.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Accessibility
Read from the source of the component.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 190 lines
'use client';
import { useRef } from 'react';
import { cn, layoutClasses, prefersReducedMotion } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive';
export type ButtonSize = 'sm' | 'md' | 'lg';
const VARIANT: Record<ButtonVariant, string> = {
primary:
'kit-shine bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]',
destructive:
'bg-destructive text-destructive-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.16),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--destructive)_88%,var(--background))]',
secondary: 'bg-secondary text-secondary-foreground shadow-[var(--highlight)] hover:bg-accent',
outline: 'border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] hover:bg-accent',
ghost: 'text-foreground hover:bg-accent',
link: 'text-foreground underline-offset-4 hover:underline px-0! h-auto! min-h-0!',
};
const SIZE: Record<ButtonSize, string> = {
// Small buttons grow to 44 px on phones, the minimum for a finger
sm: 'h-9 min-h-9 gap-1.5 rounded-md px-3.5 text-[13px] max-sm:h-11 max-sm:min-h-11',
md: 'h-11 min-h-11 gap-2 rounded-lg px-5 text-sm',
lg: 'h-12 min-h-12 gap-2 rounded-lg px-6 text-[15px]',
};
/* ------------------------------------------------------------------ */
/* Press feedback */
/* ------------------------------------------------------------------ */
/**
* A short light at the point of the press, drawn with the Web Animations API
* so nothing lives in the React tree. glow: a white burst of light for filled main
* actions. ripple: a quiet ring of the text color for everything else.
* The layer sits behind the label, clips to the radius and removes itself.
* The element needs position relative and isolation (Button has both).
*/
export function pressLight(el: HTMLElement, clientX: number, clientY: number, kind: 'glow' | 'ripple' = 'ripple') {
if (prefersReducedMotion() || typeof el.animate !== 'function') return;
const r = el.getBoundingClientRect();
if (!r.width || !r.height) return;
const x = Math.min(Math.max(clientX - r.left, 0), r.width);
const y = Math.min(Math.max(clientY - r.top, 0), r.height);
const size = Math.ceil(Math.hypot(Math.max(x, r.width - x), Math.max(y, r.height - y)) * 2);
const clip = document.createElement('span');
clip.setAttribute('aria-hidden', 'true');
clip.style.cssText = 'position:absolute;inset:0;z-index:-1;border-radius:inherit;overflow:hidden;pointer-events:none;';
const dot = document.createElement('span');
dot.style.cssText = `position:absolute;left:${Math.round(x - size / 2)}px;top:${Math.round(y - size / 2)}px;width:${size}px;height:${size}px;border-radius:9999px;background:${
kind === 'glow'
? 'radial-gradient(circle closest-side, rgb(255 255 255 / 0.42), rgb(255 255 255 / 0.12) 55%, transparent)'
: 'color-mix(in oklab, currentColor 13%, transparent)'
};`;
clip.append(dot);
el.append(clip);
const anim = dot.animate(
[
{ transform: 'scale(0.08)', opacity: kind === 'glow' ? 1 : 0.9 },
{ transform: 'scale(1)', opacity: 0 },
],
{ duration: kind === 'glow' ? 700 : 560, easing: 'cubic-bezier(0.2,0.7,0.2,1)' },
);
const done = () => clip.remove();
anim.onfinish = done;
anim.oncancel = done;
}
/** Pointer press at the pointer, keyboard press (a click with detail 0) from the middle */
function usePress(kind: 'glow' | 'ripple' | null) {
return {
onPointerDown: (e: React.PointerEvent<HTMLElement>) => {
if (kind && e.button === 0) pressLight(e.currentTarget, e.clientX, e.clientY, kind);
},
onClick: (e: React.MouseEvent<HTMLElement>) => {
if (!kind || e.detail !== 0) return;
const r = e.currentTarget.getBoundingClientRect();
pressLight(e.currentTarget, r.left + r.width / 2, r.top + r.height / 2, kind);
},
};
}
/** Pulls its child a few pixels toward a mouse pointer. No effect on touch or with reduced motion. */
function MagneticWrap({ strength = 6, className, children }: { strength?: number; className?: string; children: React.ReactNode }) {
const ref = useRef<HTMLSpanElement>(null);
return (
<span
ref={ref}
onPointerMove={(e) => {
const el = ref.current;
if (!el || e.pointerType !== 'mouse' || prefersReducedMotion()) return;
const r = el.getBoundingClientRect();
const x = ((e.clientX - r.left) / r.width - 0.5) * 2 * strength;
const y = ((e.clientY - r.top) / r.height - 0.5) * 2 * strength;
el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`;
}}
onPointerLeave={() => {
if (ref.current) ref.current.style.transform = '';
}}
className={cn('inline-flex transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', className)}
>
{children}
</span>
);
}
/* ------------------------------------------------------------------ */
/* Button */
/* ------------------------------------------------------------------ */
interface Common {
variant?: ButtonVariant;
size?: ButtonSize;
/** Icon after the label, e.g. arrow-right. Moves a little on hover. */
icon?: IconName;
/** Icon before the label */
iconLeft?: IconName;
/** Pulls the button a few pixels toward the mouse. For the one main action of a section. */
magnetic?: boolean;
className?: string;
children?: React.ReactNode;
}
type AsLink = Common & { href: string; ref?: React.Ref<HTMLAnchorElement> } & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof Common>;
type AsButton = Common & { href?: undefined; ref?: React.Ref<HTMLButtonElement> } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof Common>;
/**
* Button or link that looks like a button. With href it renders an <a>.
* A press scales it down a little and leaves a light at the press point:
* a glow on primary and destructive, a quiet ripple on the others.
*/
export function Button(props: AsLink | AsButton) {
const { variant = 'primary', size = 'md', icon, iconLeft, magnetic, className, children, ...rest } = props;
const press = usePress(variant === 'link' ? null : variant === 'primary' || variant === 'destructive' ? 'glow' : 'ripple');
const classes = cn(
'group/button relative isolate inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium tracking-[-0.01em] transition-[background-color,color,box-shadow,transform,scale,translate] duration-200 ease-[var(--ease-kit)] active:scale-[0.97] active:duration-100 disabled:pointer-events-none disabled:opacity-50 motion-reduce:active:scale-100',
SIZE[size],
VARIANT[variant],
className,
);
const inner = (
<>
{iconLeft && <Icon name={iconLeft} size={size === 'sm' ? 15 : 17} />}
{children}
{icon && <Icon name={icon} size={size === 'sm' ? 15 : 17} className="transition-transform duration-200 group-hover/button:translate-x-0.5 motion-reduce:transition-none" />}
</>
);
let el: React.ReactNode;
if ('href' in rest && rest.href !== undefined) {
const link = rest as React.AnchorHTMLAttributes<HTMLAnchorElement>;
el = (
<a
{...link}
onPointerDown={(e) => {
press.onPointerDown(e);
link.onPointerDown?.(e);
}}
onClick={(e) => {
press.onClick(e);
link.onClick?.(e);
}}
className={classes}
>
{inner}
</a>
);
} else {
const { type = 'button', ...buttonRest } = rest as React.ButtonHTMLAttributes<HTMLButtonElement>;
el = (
<button
type={type}
{...buttonRest}
onPointerDown={(e) => {
press.onPointerDown(e);
buttonRest.onPointerDown?.(e);
}}
onClick={(e) => {
press.onClick(e);
buttonRest.onClick?.(e);
}}
className={classes}
>
{inner}
</button>
);
}
// The wrapper takes over the button's place in the layout (width, flex, grid, self), so a
// full-width magnetic button stays full width on phones
return magnetic ? <MagneticWrap className={layoutClasses(className)}>{el}</MagneticWrap> : el;
}Frequent questions
How do I install Button?
With aiw add @ai-website/button. The command puts components/ui/button.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Button have?
7 variants, Styles, Sizes, With icons, Loading, Groups, Icon buttons and Neutral sign-in. You pick one with the variant prop, for example variant="outline".
Can an AI assistant build with Button?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Button with aiw_components and install it with aiw_add @ai-website/button. Name the variant in your prompt, for example variant="secondary|outline|ghost|link|destructive", and the assistant writes your content straight into the props.