IconButton
Server-safeA square, icon-only action that requires an accessible name.
Live preview
Rendered from the installed VEXA source — the same files you receive.
Playground
Adjust the props and copy the matching JSX.
Playground
<IconButton aria-label="Action">
<PlusIcon />
</IconButton>Installation
Adds the component source to your project. Registry dependencies are pulled in automatically.
npx shadcn@latest add https://vexa.valfiguer.com/r/icon-button.jsonInstalls to components/vexa/icon-button.tsx
npm dependencies
- @radix-ui/react-slot
- class-variance-authority
Registry dependencies
- @vexa/utils
Other VEXA items this component needs. Install them first — they are resolved automatically by the CLI.
Source
Copy this into its target path, or install it with the command above.
import type * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
export const iconButtonVariants = cva(
[
"inline-flex select-none items-center justify-center",
"rounded-vexa-md text-sm font-medium",
"transition-[color,background-color,border-color,box-shadow]",
"duration-[var(--vexa-duration-fast)] ease-[var(--vexa-ease-standard)]",
"outline-none focus-visible:ring-2 focus-visible:ring-vexa-ring",
"focus-visible:ring-offset-2 focus-visible:ring-offset-vexa-background",
"disabled:pointer-events-none disabled:opacity-50",
"aria-[busy=true]:pointer-events-none aria-[busy=true]:cursor-progress",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
],
{
variants: {
variant: {
primary:
"bg-vexa-primary text-vexa-primary-foreground hover:bg-vexa-primary/90 active:bg-vexa-primary/95",
secondary:
"border border-vexa-border bg-vexa-secondary text-vexa-secondary-foreground hover:bg-vexa-accent active:bg-vexa-accent",
outline:
"border border-vexa-input bg-transparent text-vexa-foreground hover:bg-vexa-accent hover:text-vexa-accent-foreground active:bg-vexa-accent",
ghost:
"bg-transparent text-vexa-foreground hover:bg-vexa-accent hover:text-vexa-accent-foreground active:bg-vexa-accent",
danger:
"bg-vexa-danger text-vexa-danger-foreground hover:bg-vexa-danger/90 active:bg-vexa-danger/95",
},
size: {
sm: "size-8 [&_svg]:size-4",
md: "size-10 [&_svg]:size-5",
lg: "size-12 [&_svg]:size-6",
},
},
defaultVariants: {
variant: "ghost",
size: "md",
},
},
);
/**
* An IconButton MUST have an accessible name. This type enforces at least one
* of `aria-label` or `aria-labelledby` at compile time, since the button has no
* visible text label.
*/
type AccessibleName =
| { "aria-label": string; "aria-labelledby"?: never }
| { "aria-labelledby": string; "aria-label"?: never };
export type IconButtonProps = Omit<
React.ComponentProps<"button">,
"aria-label" | "aria-labelledby"
> &
VariantProps<typeof iconButtonVariants> & {
asChild?: boolean;
} & AccessibleName;
/**
* IconButton — a square, icon-only action. Requires an accessible name.
*
* RSC-safe. Square touch targets: `md` and `lg` meet the 44×44 CSS-pixel
* guidance; use `md`+ for primary touch actions.
*/
export function IconButton({
className,
variant,
size,
asChild = false,
type,
...props
}: IconButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="icon-button"
className={cn(iconButtonVariants({ variant, size }), className)}
type={asChild ? undefined : (type ?? "button")}
{...props}
/>
);
}
Registry JSONr/icon-button.json
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "icon-button",
"type": "registry:ui",
"title": "IconButton",
"description": "A square, icon-only action that requires an accessible name.",
"dependencies": [
"@radix-ui/react-slot",
"class-variance-authority"
],
"registryDependencies": [
"@vexa/utils"
],
"files": [
{
"path": "registry/default/ui/icon-button.tsx",
"type": "registry:ui",
"target": "components/vexa/icon-button.tsx",
"content": "import type * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport const iconButtonVariants = cva(\n [\n \"inline-flex select-none items-center justify-center\",\n \"rounded-vexa-md text-sm font-medium\",\n \"transition-[color,background-color,border-color,box-shadow]\",\n \"duration-[var(--vexa-duration-fast)] ease-[var(--vexa-ease-standard)]\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-vexa-ring\",\n \"focus-visible:ring-offset-2 focus-visible:ring-offset-vexa-background\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"aria-[busy=true]:pointer-events-none aria-[busy=true]:cursor-progress\",\n \"[&_svg]:pointer-events-none [&_svg]:shrink-0\",\n ],\n {\n variants: {\n variant: {\n primary:\n \"bg-vexa-primary text-vexa-primary-foreground hover:bg-vexa-primary/90 active:bg-vexa-primary/95\",\n secondary:\n \"border border-vexa-border bg-vexa-secondary text-vexa-secondary-foreground hover:bg-vexa-accent active:bg-vexa-accent\",\n outline:\n \"border border-vexa-input bg-transparent text-vexa-foreground hover:bg-vexa-accent hover:text-vexa-accent-foreground active:bg-vexa-accent\",\n ghost:\n \"bg-transparent text-vexa-foreground hover:bg-vexa-accent hover:text-vexa-accent-foreground active:bg-vexa-accent\",\n danger:\n \"bg-vexa-danger text-vexa-danger-foreground hover:bg-vexa-danger/90 active:bg-vexa-danger/95\",\n },\n size: {\n sm: \"size-8 [&_svg]:size-4\",\n md: \"size-10 [&_svg]:size-5\",\n lg: \"size-12 [&_svg]:size-6\",\n },\n },\n defaultVariants: {\n variant: \"ghost\",\n size: \"md\",\n },\n },\n);\n\n/**\n * An IconButton MUST have an accessible name. This type enforces at least one\n * of `aria-label` or `aria-labelledby` at compile time, since the button has no\n * visible text label.\n */\ntype AccessibleName =\n | { \"aria-label\": string; \"aria-labelledby\"?: never }\n | { \"aria-labelledby\": string; \"aria-label\"?: never };\n\nexport type IconButtonProps = Omit<\n React.ComponentProps<\"button\">,\n \"aria-label\" | \"aria-labelledby\"\n> &\n VariantProps<typeof iconButtonVariants> & {\n asChild?: boolean;\n } & AccessibleName;\n\n/**\n * IconButton — a square, icon-only action. Requires an accessible name.\n *\n * RSC-safe. Square touch targets: `md` and `lg` meet the 44×44 CSS-pixel\n * guidance; use `md`+ for primary touch actions.\n */\nexport function IconButton({\n className,\n variant,\n size,\n asChild = false,\n type,\n ...props\n}: IconButtonProps) {\n const Comp = asChild ? Slot : \"button\";\n return (\n <Comp\n data-slot=\"icon-button\"\n className={cn(iconButtonVariants({ variant, size }), className)}\n type={asChild ? undefined : (type ?? \"button\")}\n {...props}\n />\n );\n}\n"
}
],
"meta": {
"frameworks": [
"react",
"next"
],
"rsc": "server-safe",
"variants": {
"variant": [
"primary",
"secondary",
"outline",
"ghost",
"danger"
],
"size": [
"sm",
"md",
"lg"
]
},
"cssVars": [
"--vexa-primary",
"--vexa-primary-foreground",
"--vexa-secondary",
"--vexa-accent",
"--vexa-accent-foreground",
"--vexa-danger",
"--vexa-danger-foreground",
"--vexa-border",
"--vexa-input",
"--vexa-ring",
"--vexa-background"
],
"a11y": "Type-level requirement: `aria-label` or `aria-labelledby` is mandatory. Sizes md/lg meet the 44x44 target guidance.",
"example": "<IconButton aria-label=\"Open menu\"><MenuIcon /></IconButton>",
"tests": "planned — see docs/README.md#roadmap"
}
}Details
Variants
- variant
- primarysecondaryoutlineghostdanger
- size
- smmdlg
Accessibility
Type-level requirement: `aria-label` or `aria-labelledby` is mandatory. Sizes md/lg meet the 44x44 target guidance.
Usage
A square button whose only content is an icon — toolbars, close affordances, and compact actions where a text label would not fit.
Anatomy
- IconButton
- Square button expecting a single icon child.
- iconButtonVariants
- cva helper for the icon-button variant/size classes.
Accessibility
Native `<button>`. An accessible name is mandatory and enforced at the type level: pass `aria-label` or `aria-labelledby`.
Keyboard
| Key | Action |
|---|---|
| Enter / Space | Activate the button |
Focus: Visible `focus-visible` ring; native `disabled` handling.
- Target Size (WCAG 2.2 §2.5.8): sizes `md`/`lg` meet the 44×44 guidance; avoid `sm` for primary touch targets.
Guidelines
Do
- Always pass `aria-label`.
- Pair with a Tooltip for discoverability.
- Use `md`/`lg` for touch-first UIs.
Don't
- Don't ship without an accessible name.
- Don't place text or multiple glyphs inside.
- Don't use `sm` as a primary touch target.
Was this page helpful?
Stored locally in this browser. No account or server needed.