UIInteractive Buttons and Controlsno dependencies

Button

A versatile button with six variants, four sizes, a springy press scale, leading/trailing icon slots and a loading state where a spinner slides in beside the label. Renders an anchor when given an href.

Preview

Customize

11 props
0.97

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add button

Writes components/ui/button.tsx and components/styles/button.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { Button } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Button />6  );7}

Props

PropTypeDefault
label

Button text; also the aria-label for the icon size. Ignored when children are passed.

string'Get started'
children

Custom content that replaces the label.

React.ReactNodeundefined
variant

Visual style.

'default' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link''default'
size

Size preset; 'icon' renders a square icon-only button.

'sm' | 'md' | 'lg' | 'icon''md'
icon

Built-in inline SVG icon used when no custom icon slot is provided.

'none' | 'arrow' | 'plus' | 'download' | 'sparkle' | 'check''arrow'
iconPosition

Side of the label the built-in icon sits on.

'leading' | 'trailing''trailing'
leadingIcon

Custom leading icon slot.

React.ReactNodeundefined
trailingIcon

Custom trailing icon slot (nudges right on hover).

React.ReactNodeundefined
loading

Slides in a spinner, sets aria-busy and blocks clicks.

booleanfalse
loadingText

Label shown while loading; falls back to the normal label.

string''
disabled

Disables the button.

booleanfalse
pressScale

Scale applied while pressed (1 disables the effect).

number0.97
accentColor

Brand color for the default and link variants.

string'#6366f1'
fullWidth

Stretch to the container width.

booleanfalse
href

Render an <a> pointing here instead of a <button>.

stringundefined
target

Anchor target; '_blank' adds rel="noreferrer noopener".

stringundefined
type

Native button type.

'button' | 'submit' | 'reset''button'
onClick

Click handler (not called while loading or disabled).

(event: React.MouseEvent<HTMLElement>) => voidundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

Renders a native <button> (or <a> when href is set) so Enter/Space and focus work natively, with a visible :focus-visible ring. Icon-only size uses `label` as aria-label. Loading sets aria-busy and aria-disabled and ignores clicks; a disabled anchor drops its href, sets aria-disabled and keeps focusability. Icons are aria-hidden. Under prefers-reduced-motion the press scale and slide transitions are removed.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/button.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/button.module.css";5 6type BuiltInIcon = "none" | "arrow" | "plus" | "download" | "sparkle" | "check";7 8export interface ButtonProps {9  /** Button text (ignored when children are passed) */10  label?: string;11  /** Custom content; overrides `label` */12  children?: React.ReactNode;13  /** Visual style */14  variant?: "default" | "secondary" | "outline" | "ghost" | "destructive" | "link";15  /** Size preset; `icon` renders a square icon-only button (uses `label` as its accessible name) */16  size?: "sm" | "md" | "lg" | "icon";17  /** Built-in inline SVG icon used when no custom icon slot is provided */18  icon?: BuiltInIcon;19  /** Where the built-in icon sits */20  iconPosition?: "leading" | "trailing";21  /** Custom leading icon slot (overrides the built-in icon) */22  leadingIcon?: React.ReactNode;23  /** Custom trailing icon slot (overrides the built-in icon) */24  trailingIcon?: React.ReactNode;25  /** Show a spinner and block interaction */26  loading?: boolean;27  /** Text shown while loading (defaults to the label) */28  loadingText?: string;29  /** Disable the button */30  disabled?: boolean;31  /** Scale applied while pressed (1 = none) */32  pressScale?: number;33  /** Brand color for the default and link variants */34  accentColor?: string;35  /** Stretch to the container width */36  fullWidth?: boolean;37  /** Render an <a> with this href instead of a <button> */38  href?: string;39  /** Anchor target when `href` is set */40  target?: string;41  /** Native button type */42  type?: "button" | "submit" | "reset";43  /** Click handler */44  onClick?: (event: React.MouseEvent<HTMLElement>) => void;45  /** Optional className merged onto the root */46  className?: string;47  /** Optional inline styles merged onto the root */48  style?: React.CSSProperties;49}50 51const ICON_PATHS: Record<Exclude<BuiltInIcon, "none">, string> = {52  arrow: "M5 12h14M13 6l6 6-6 6",53  plus: "M12 5v14M5 12h14",54  download: "M12 4v11M7 10l5 5 5-5M5 20h14",55  sparkle: "M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z",56  check: "M5 12.5l4.5 4.5L19 7",57};58 59function Icon({ name }: { name: Exclude<BuiltInIcon, "none"> }) {60  return (61    <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">62      <path d={ICON_PATHS[name]} />63    </svg>64  );65}66 67export function Button({68  label = "Get started",69  children,70  variant = "default",71  size = "md",72  icon = "arrow",73  iconPosition = "trailing",74  leadingIcon,75  trailingIcon,76  loading = false,77  loadingText = "",78  disabled = false,79  pressScale = 0.97,80  accentColor = "#6366f1",81  fullWidth = false,82  href,83  target,84  type = "button",85  onClick,86  className = "",87  style,88}: ButtonProps) {89  const inactive = disabled || loading;90  const isIconOnly = size === "icon";91  const builtIn = icon !== "none" ? <Icon name={icon} /> : null;92  const lead = leadingIcon ?? (iconPosition === "leading" || isIconOnly ? builtIn : null);93  const trail = trailingIcon ?? (iconPosition === "trailing" && !isIconOnly ? builtIn : null);94  const text = loading && loadingText ? loadingText : children ?? label;95 96  const cls = [styles.root, styles[variant], styles[size], fullWidth ? styles.full : "", className].filter(Boolean).join(" ");97  const rootStyle = { "--mf-btn-accent": accentColor, "--mf-btn-press": String(pressScale), ...style } as React.CSSProperties;98 99  const content = (100    <>101      <span className={styles.spinnerSlot} data-on={loading || undefined} aria-hidden="true">102        <span className={styles.spinner} />103      </span>104      {lead && !(isIconOnly && loading) ? <span className={styles.glyph}>{lead}</span> : null}105      {isIconOnly ? null : <span className={styles.label}>{text}</span>}106      {trail ? <span className={`${styles.glyph} ${styles.trail}`}>{trail}</span> : null}107    </>108  );109 110  const shared = {111    className: cls,112    style: rootStyle,113    "data-loading": loading || undefined,114    "aria-busy": loading || undefined,115    "aria-label": isIconOnly ? label : undefined,116  };117 118  if (href) {119    return (120      <a121        {...shared}122        href={inactive ? undefined : href}123        target={target}124        rel={target === "_blank" ? "noreferrer noopener" : undefined}125        aria-disabled={inactive || undefined}126        role={inactive ? "link" : undefined}127        tabIndex={inactive ? 0 : undefined}128        onClick={(e) => {129          if (inactive) {130            e.preventDefault();131            return;132          }133          onClick?.(e);134        }}135      >136        {content}137      </a>138    );139  }140 141  return (142    <button {...shared} type={type} disabled={disabled} aria-disabled={loading || undefined} onClick={(e) => !inactive && onClick?.(e)}>143      {content}144    </button>145  );146}