Actions
Icon button
Icon buttons in four styles and three widths, with toggle mode.
Icon buttons perform compact actions where an icon is enough: toolbars, app bars, cards. Choose from four styles and three widths; use the toggle version for on/off actions like favorite.
Installation
npx @grenishrai/expressive add icon-buttonThis copies icon-button.tsx into your components folder, along with what it needs from Expressive (utils, ripple, styles), and installs @base-ui/react, class-variance-authority.
Usage
import { IconButton, ToggleIconButton } from "@/components/expressive/icon-button";
<IconButton aria-label="Search">
<SearchIcon />
</IconButton>
<ToggleIconButton aria-label="Favorite" variant="filled">
<HeartIcon />
</ToggleIconButton>Props
IconButton
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label | string | โ | Required (or aria-labelledby): there is no visible text. Enforced by the types. |
| variant | "standard" | "filled" | "tonal" | "outlined" | "standard" | Visual emphasis. |
| size | "xs" | "sm" | "md" | "lg" | "xl" | "sm" | Container height, 32โ136px. |
| width | "narrow" | "default" | "wide" | "default" | Container width relative to its height. |
| shape | "round" | "square" | "round" | Resting corner shape. |
| motion | "expressive" | "standard" | "fluid" | "none" | theme default | Motion scheme for this component's transitions. none turns animation off. |
| className | string | โ | Extra classes, merged with tailwind-merge. |
ToggleIconButton
| Prop | Type | Default | Description |
|---|---|---|---|
| pressed / defaultPressed | boolean | โ | Selected state. |
| onPressedChange | (pressed: boolean) => void | โ | Called when the selection changes. |
Accessibility
- The accessible name is required at the type level, so icon-only buttons can't ship unlabelled.
- Pair with a Tooltip to show the name visually on hover and focus.
- Toggle icon buttons expose
aria-pressed.
Source
This is the file the CLI writes into your project, shown with the default import paths. It's yours to change.
"use client";
import { Button as BaseButton } from "@base-ui/react/button";
import { Toggle as BaseToggle } from "@base-ui/react/toggle";
import { type VariantProps, cva } from "class-variance-authority";
import { cn } from "@/lib/expressive/utils";
import type { MotionProp } from "@/lib/expressive/use-motion";
import * as React from "react";
import { useComposedRef, useFluidPress } from "@/lib/expressive/use-fluid";
import { Ripple } from "@/components/expressive/ripple";
export const iconButtonVariants = cva(
[
"state-layer focus-ring relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center",
"transition-[border-radius,background-color,color] spring-spatial-fast",
"disabled:cursor-default disabled:text-on-surface/(--ex-disabled-content)",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
],
{
variants: {
variant: {
standard: "text-on-surface-variant data-pressed:text-primary",
filled:
"bg-primary text-on-primary disabled:bg-on-surface/(--ex-disabled-container)",
tonal:
"bg-secondary-container text-on-secondary-container disabled:bg-on-surface/(--ex-disabled-container)",
outlined:
"border border-outline-variant text-on-surface-variant disabled:border-on-surface/(--ex-disabled-container) data-pressed:border-transparent data-pressed:bg-inverse-surface data-pressed:text-inverse-on-surface",
},
size: {
xs: "h-(--ex-control-height) [--ex-control-height:2rem] [&_svg]:size-5",
sm: "h-(--ex-control-height) [--ex-control-height:2.5rem] [&_svg]:size-6",
md: "h-(--ex-control-height) [--ex-control-height:3.5rem] [&_svg]:size-6",
lg: "h-(--ex-control-height) [--ex-control-height:6rem] [&_svg]:size-8",
xl: "h-(--ex-control-height) [--ex-control-height:8.5rem] [&_svg]:size-10",
},
width: {
narrow: "",
default: "",
wide: "",
},
shape: {
round: "rounded-pill",
square: "",
},
toggle: { true: "", false: "" },
},
compoundVariants: [
{ size: "xs", width: "narrow", className: "w-7" },
{ size: "xs", width: "default", className: "w-8" },
{ size: "xs", width: "wide", className: "w-10" },
{ size: "sm", width: "narrow", className: "w-8" },
{ size: "sm", width: "default", className: "w-10" },
{ size: "sm", width: "wide", className: "w-13" },
{ size: "md", width: "narrow", className: "w-12" },
{ size: "md", width: "default", className: "w-14" },
{ size: "md", width: "wide", className: "w-18" },
{ size: "lg", width: "narrow", className: "w-16" },
{ size: "lg", width: "default", className: "w-24" },
{ size: "lg", width: "wide", className: "w-32" },
{ size: "xl", width: "narrow", className: "w-26" },
{ size: "xl", width: "default", className: "w-34" },
{ size: "xl", width: "wide", className: "w-46" },
{ shape: "square", size: ["xs", "sm"], className: "rounded-shape-medium" },
{ shape: "square", size: "md", className: "rounded-shape-large" },
{ shape: "square", size: ["lg", "xl"], className: "rounded-shape-extra-large" },
{ size: ["xs", "sm"], className: "active:rounded-shape-small" },
{ size: "md", className: "active:rounded-shape-medium" },
{ size: ["lg", "xl"], className: "active:rounded-shape-large" },
{ toggle: true, shape: "round", size: ["xs", "sm"], className: "data-pressed:rounded-shape-medium" },
{ toggle: true, shape: "round", size: "md", className: "data-pressed:rounded-shape-large" },
{ toggle: true, shape: "round", size: ["lg", "xl"], className: "data-pressed:rounded-shape-extra-large" },
{ toggle: true, shape: "square", className: "data-pressed:rounded-pill" },
{ toggle: true, variant: "filled", className: "bg-surface-container text-on-surface-variant data-pressed:bg-primary data-pressed:text-on-primary" },
{ toggle: true, variant: "tonal", className: "data-pressed:bg-secondary data-pressed:text-on-secondary" },
],
defaultVariants: { variant: "standard", size: "sm", width: "default", shape: "round", toggle: false },
},
);
/** Icon-only buttons have no visible text, so an accessible name is required. */
type AccessibleName = { "aria-label": string } | { "aria-labelledby": string };
type OwnProps = Omit<VariantProps<typeof iconButtonVariants>, "toggle"> & {
motion?: MotionProp;
className?: string;
ref?: React.Ref<HTMLElement>;
};
export type IconButtonProps = OwnProps & Omit<BaseButton.Props, "className"> & AccessibleName;
export type ToggleIconButtonProps = OwnProps & Omit<BaseToggle.Props, "className"> & AccessibleName;
export function IconButton({ className, variant, size, width, shape, motion, children, disabled, ref, ...props }: IconButtonProps) {
const local = React.useRef<HTMLElement>(null);
useFluidPress(local, motion, !disabled);
return (
<BaseButton
ref={useComposedRef(local, ref)}
data-slot="icon-button"
data-motion={motion}
disabled={disabled}
className={cn(iconButtonVariants({ variant, size, width, shape }), className)}
{...props}
>
{children}
<Ripple motion={motion} disabled={disabled} />
</BaseButton>
);
}
export function ToggleIconButton({ className, variant, size, width, shape, motion, children, disabled, ref, ...props }: ToggleIconButtonProps) {
const local = React.useRef<HTMLElement>(null);
useFluidPress(local, motion, !disabled);
return (
<BaseToggle
ref={useComposedRef(local, ref)}
data-slot="icon-button"
data-motion={motion}
disabled={disabled}
className={cn(iconButtonVariants({ variant, size, width, shape, toggle: true }), className)}
{...props}
>
{children}
<Ripple motion={motion} disabled={disabled} />
</BaseToggle>
);
}