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