Skip to content
UI Kit
Accent color

Button component for React

Five styles, three sizes, icons, loading, groups, icon buttons and neutral sign-in buttons.

One of 6 components in Buttons

7 variants

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

Styles

One primary action per view, the others step back. A press dips the button and leaves a light where you pressed, the main action follows the mouse a little.

Sizes

Small 36 px, medium 44 px, large 48 px. Medium and large meet the 44 px tap target.

With icons

Icons before name the object, icons after point the way and move a little on hover.

Loading

The spinner turns into a drawn check, the width glides between the texts, an error shakes once. Double clicks are blocked.

Groups

Attached buttons share one border, a split button keeps related actions one click away.

Icon buttons

Square or round, with a required label and an optional count. Press the bell, the count pops.

Neutral sign-in

Full-width options for sign-in, drawn without third-party logos. Add your provider marks where you have the rights.

Details

What Button brings along, read from the registry and the source.

Category
Buttons
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

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
components/ui/button.tsx
'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.

Often searched

Jump to

Copied to the clipboard