Icon button
Buttons that are only an icon, with a required label, four sizes, five styles and a count.
One of 6 components in Buttons
Details
What Icon button brings along, read from the registry and the source.
- Category
- Buttons
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/icon-button.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 170 lines
'use client';
import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
export type IconButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'soft';
export type IconButtonSize = 'xs' | 'sm' | 'md' | 'lg';
const VARIANT: Record<IconButtonVariant, string> = {
primary: 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]',
secondary: 'bg-secondary text-secondary-foreground shadow-[var(--highlight)] hover:bg-accent',
outline: 'border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] hover:bg-accent',
ghost: 'text-muted-foreground hover:bg-accent hover:text-foreground aria-pressed:bg-accent aria-pressed:text-foreground',
soft: 'bg-[color-mix(in_oklab,var(--primary)_11%,transparent)] text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))] hover:bg-[color-mix(in_oklab,var(--primary)_17%,transparent)]',
};
/* Small sizes keep a 44 px hit area through an invisible ::after */
const SIZE: Record<IconButtonSize, { box: string; icon: number }> = {
xs: { box: 'size-7 rounded-md after:-inset-2', icon: 15 },
sm: { box: 'size-9 rounded-md after:-inset-1', icon: 17 },
md: { box: 'size-11 rounded-lg', icon: 19 },
lg: { box: 'size-12 rounded-lg', icon: 20 },
};
export function iconButtonClass({ variant = 'ghost', size = 'md', round, className }: { variant?: IconButtonVariant; size?: IconButtonSize; round?: boolean; className?: string }) {
return cn(
'group/icon-button relative isolate inline-grid shrink-0 select-none place-items-center transition-[background-color,color,box-shadow,transform,scale] duration-200 ease-[var(--ease-kit)] after:absolute after:content-[""] active:scale-[0.92] active:duration-100 disabled:pointer-events-none disabled:opacity-45 motion-reduce:active:scale-100',
SIZE[size].box,
VARIANT[variant],
round && 'rounded-full',
className,
);
}
/**
* A quiet ring that spreads from the press point and fades, behind the icon.
* Pass as onPointerDown on any element styled with iconButtonClass.
*/
export function iconPress(e: React.PointerEvent<HTMLElement> | React.MouseEvent<HTMLElement>) {
const el = e.currentTarget;
if (prefersReducedMotion() || typeof el.animate !== 'function') return;
if ('button' in e && e.button !== 0) return;
const r = el.getBoundingClientRect();
// A keyboard press (click with detail 0) starts in the middle
const keyboard = e.type === 'click';
const x = keyboard ? r.width / 2 : e.clientX - r.left;
const y = keyboard ? r.height / 2 : e.clientY - r.top;
const size = Math.ceil(Math.hypot(Math.max(x, r.width - x), Math.max(y, r.height - y)) * 2);
const clip = document.createElement('span');
clip.setAttribute('aria-hidden', 'true');
clip.style.cssText = 'position:absolute;inset:0;z-index:-1;border-radius:inherit;overflow:hidden;pointer-events:none;';
const dot = document.createElement('span');
dot.style.cssText = `position:absolute;left:${Math.round(x - size / 2)}px;top:${Math.round(y - size / 2)}px;width:${size}px;height:${size}px;border-radius:9999px;background:color-mix(in oklab, currentColor 16%, transparent);`;
clip.append(dot);
el.append(clip);
const anim = dot.animate([{ transform: 'scale(0.1)', opacity: 1 }, { transform: 'scale(1)', opacity: 0 }], { duration: 520, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
anim.onfinish = anim.oncancel = () => clip.remove();
}
/** A spring pop: small, a little too big, then at rest */
const POP: Keyframe[] = [
{ transform: 'scale(0.3)', opacity: 0 },
{ transform: 'scale(1.18)', opacity: 1, offset: 0.55 },
{ transform: 'scale(0.96)', offset: 0.8 },
{ transform: 'scale(1)', opacity: 1 },
];
/** Pops once when it first comes into view and again whenever the count changes */
function Badge({ badge, size }: { badge: number | boolean; size: IconButtonSize }) {
const ref = useRef<HTMLSpanElement>(null);
const last = useRef(badge);
const pop = () => {
const el = ref.current;
if (!el || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate(POP, { duration: 520, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
};
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
const io = new IntersectionObserver(([e]) => {
if (!e.isIntersecting) return;
io.disconnect();
pop();
});
io.observe(el);
return () => io.disconnect();
}, []);
useEffect(() => {
if (last.current === badge) return;
last.current = badge;
pop();
}, [badge]);
if (badge === false || badge === 0) return null;
if (badge === true)
return <span ref={ref} aria-hidden className={cn('absolute rounded-full bg-primary ring-2 ring-background', size === 'xs' || size === 'sm' ? 'right-1.5 top-1.5 size-2' : 'right-2 top-2 size-2.5')} />;
return (
<span ref={ref} aria-hidden className="absolute -right-1 -top-1 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-primary px-1 text-[10.5px] font-semibold tabular-nums leading-none text-primary-foreground ring-2 ring-background">
{badge > 99 ? '99+' : badge}
</span>
);
}
interface Common {
icon: IconName;
/** Required: the accessible name, also used as the native title when no tooltip wraps it */
label: string;
variant?: IconButtonVariant;
size?: IconButtonSize;
/** circle instead of the rounded square */
round?: boolean;
/** A count or a dot in the corner, e.g. unread messages. true shows a dot. Pops in and on every change. */
badge?: number | boolean;
className?: string;
}
type AsButton = Common & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof Common | 'children'> & { href?: undefined; ref?: React.Ref<HTMLButtonElement> };
type AsLink = Common & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof Common | 'children'> & { href: string; ref?: React.Ref<HTMLAnchorElement> };
/**
* A button that is only an icon. The label is required and becomes the accessible name.
* A press scales it down and sends a quiet ring out from the press point.
* <IconButton icon="bell" label="Notifications" badge={3} />
*/
export function IconButton(props: AsButton | AsLink) {
const { icon, label, variant, size = 'md', round, badge, className, ...rest } = props;
const classes = iconButtonClass({ variant, size, round, className });
const name = typeof badge === 'number' && badge > 0 ? `${label} (${badge})` : label;
const inner = (
<>
<Icon name={icon} size={SIZE[size].icon} />
{badge !== undefined && <Badge badge={badge} size={size} />}
</>
);
if ('href' in rest && rest.href !== undefined) {
const link = rest as React.AnchorHTMLAttributes<HTMLAnchorElement>;
return (
<a
{...link}
aria-label={name}
onPointerDown={(e) => {
iconPress(e);
link.onPointerDown?.(e);
}}
className={classes}
>
{inner}
</a>
);
}
const { type = 'button', ...buttonRest } = rest as React.ButtonHTMLAttributes<HTMLButtonElement>;
return (
<button
type={type}
{...buttonRest}
aria-label={name}
onPointerDown={(e) => {
iconPress(e);
buttonRest.onPointerDown?.(e);
}}
onClick={(e) => {
if (e.detail === 0) iconPress(e);
buttonRest.onClick?.(e);
}}
className={classes}
>
{inner}
</button>
);
}Frequent questions
How do I install Icon button?
With aiw add @ai-website/icon-button. The command puts components/ui/icon-button.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Icon button have?
3 variants, Styles and sizes, Count and dot and In context.
Can an AI assistant build with Icon button?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Icon button with aiw_components and install it with aiw_add @ai-website/icon-button. Name the variant in your prompt, for example variant="outline", and the assistant writes your content straight into the props.