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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add buttonWrites 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.
1import { Button } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Button />6 );7}Props
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
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}