UIInteractive Buttons and Controlsno dependencies

IconButton

A round or square icon-only button with a floating label that springs in on hover or keyboard focus and a ripple that expands from the exact press point. Comes with solid, soft, outline and ghost variants and seven built-in inline icons.

Preview

Customize

10 props

Installation

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

npx motionfoundry add icon-button

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

Usage

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

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

Props

PropTypeDefault
label

Accessible name, also shown as the floating hover/focus label.

string'Notifications'
icon

Built-in inline SVG icon.

'bell' | 'heart' | 'settings' | 'share' | 'plus' | 'trash' | 'search''bell'
children

Custom icon that replaces the built-in one.

React.ReactNodeundefined
variant

Visual style.

'solid' | 'soft' | 'outline' | 'ghost''soft'
shape

Circle or rounded-square corners.

'round' | 'square''round'
size

32, 40 or 48px square.

'sm' | 'md' | 'lg''md'
showLabel

Show the floating label on hover and keyboard focus.

booleantrue
labelSide

Side the floating label appears on.

'top' | 'bottom''top'
ripple

Expand a ripple from the press point.

booleantrue
accentColor

Brand color for solid/soft variants.

string'#6366f1'
disabled

Disable the button.

booleanfalse
onClick

Click handler.

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

Additional class merged onto the root wrapper.

string''
style

Inline styles merged onto the root wrapper.

React.CSSPropertiesundefined

Accessibility

Native <button> with `label` as its aria-label, so the icon-only control always has an accessible name. The floating label is a visual duplicate (aria-hidden) shown on hover and :focus-visible. Ripples also fire for Enter/Space presses and are aria-hidden. Under prefers-reduced-motion ripples, hover tilt and press scale are disabled.

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

Source

components/ui/icon-button.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/icon-button.module.css";5 6type IconName = "bell" | "heart" | "settings" | "share" | "plus" | "trash" | "search";7 8export interface IconButtonProps {9  /** Accessible name, also shown as the hover/focus label */10  label?: string;11  /** Built-in inline SVG icon */12  icon?: IconName;13  /** Custom icon; overrides `icon` */14  children?: React.ReactNode;15  /** Visual style */16  variant?: "solid" | "soft" | "outline" | "ghost";17  /** Corner shape */18  shape?: "round" | "square";19  /** Size preset */20  size?: "sm" | "md" | "lg";21  /** Show the floating label on hover and keyboard focus */22  showLabel?: boolean;23  /** Side the floating label appears on */24  labelSide?: "top" | "bottom";25  /** Play a ripple from the press point */26  ripple?: boolean;27  /** Brand color for solid/soft variants and the ripple */28  accentColor?: string;29  /** Disable the button */30  disabled?: boolean;31  /** Click handler */32  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;33  /** Optional className merged onto the root */34  className?: string;35  /** Optional inline styles merged onto the root */36  style?: React.CSSProperties;37}38 39const PATHS: Record<IconName, string> = {40  bell: "M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0",41  heart: "M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7z",42  settings: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z",43  share: "M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM8.6 13.5l6.8 4M15.4 6.5l-6.8 4",44  plus: "M12 5v14M5 12h14",45  trash: "M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6",46  search: "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3",47};48 49interface Ripple {50  id: number;51  x: number;52  y: number;53  r: number;54}55 56export function IconButton({57  label = "Notifications",58  icon = "bell",59  children,60  variant = "soft",61  shape = "round",62  size = "md",63  showLabel = true,64  labelSide = "top",65  ripple = true,66  accentColor = "#6366f1",67  disabled = false,68  onClick,69  className = "",70  style,71}: IconButtonProps) {72  const [ripples, setRipples] = React.useState<Ripple[]>([]);73  const nextId = React.useRef(0);74 75  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {76    if (!ripple || disabled) return;77    const rect = e.currentTarget.getBoundingClientRect();78    const x = e.clientX - rect.left;79    const y = e.clientY - rect.top;80    const r = Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y));81    const id = nextId.current++;82    setRipples((list) => [...list.slice(-3), { id, x, y, r }]);83  };84 85  const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {86    if (!ripple || disabled || e.repeat || (e.key !== "Enter" && e.key !== " ")) return;87    const { width, height } = e.currentTarget.getBoundingClientRect();88    const id = nextId.current++;89    setRipples((list) => [...list.slice(-3), { id, x: width / 2, y: height / 2, r: Math.hypot(width, height) / 2 }]);90  };91 92  const cls = [styles.root, styles[variant], styles[shape], styles[size]].filter(Boolean).join(" ");93 94  return (95    <span96      className={`${styles.wrap} ${className}`.trim()}97      data-side={labelSide}98      style={{ "--mf-ib-accent": accentColor, ...style } as React.CSSProperties}99    >100      <button101        type="button"102        aria-label={label}103        disabled={disabled}104        onPointerDown={onPointerDown}105        onKeyDown={onKeyDown}106        onClick={onClick}107        className={cls}108      >109        <span className={styles.ripples} aria-hidden="true">110          {ripples.map((rp) => (111            <span112              key={rp.id}113              className={styles.ripple}114              style={{ left: rp.x - rp.r, top: rp.y - rp.r, width: rp.r * 2, height: rp.r * 2 }}115              onAnimationEnd={() => setRipples((list) => list.filter((it) => it.id !== rp.id))}116            />117          ))}118        </span>119        <span className={styles.icon} aria-hidden="true">120          {children ?? (121            <svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">122              <path d={PATHS[icon] ?? PATHS.bell} />123            </svg>124          )}125        </span>126      </button>127      {showLabel && label ? (128        <span className={styles.tip} aria-hidden="true">129          {label}130        </span>131      ) : null}132    </span>133  );134}