Icon button
A square or round icon-only button in primary, secondary and ghost priorities and three sizes, with a required accessible name that doubles as a hover and focus hint, and an optional pressed state for toggles.
cmp_icon_button_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_icon_button_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-851c7ff9ce109b9fa6378487485bc61d020b3b93496e883353104feb4a73bc7a
<!--
An icon-only button: primary, secondary or ghost, square or round, in three sizes. The icon is
exactly half the square at every size, and the required label is both the accessible name and
a small inverted hint that appears above the button on hover and keyboard focus (flipping
below when there is no room) without moving anything around it.
-->
<script lang="ts" module>
export type IconButtonVariant = 'primary' | 'secondary' | 'ghost';
export type IconButtonSize = 'sm' | 'md' | 'lg';
export type IconButtonShape = 'square' | 'round';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
type Passthrough = Omit<
HTMLButtonAttributes,
| 'type'
| 'disabled'
| 'children'
| 'onclick'
| 'class'
| 'style'
| 'title'
| 'aria-label'
| 'aria-labelledby'
| 'aria-pressed'
>;
interface Props extends Passthrough {
/** The accessible name, and the hint's text. Name the action: "Close dialog", not "Close". */
label: string;
/** The icon, rendered inside an aria-hidden box sized to half the button. */
icon: Snippet;
/** Visual priority: a filled primary, a hairline secondary or a quiet ghost. */
variant?: IconButtonVariant;
/** sm 32 px, md 40 px, lg 48 px square; sm and md grow to 44 px on touch screens. */
size?: IconButtonSize;
/** A rounded square, or a circle. */
shape?: IconButtonShape;
/** Show the label as a hint on hover and keyboard focus. */
showHint?: boolean;
/** When set, renders aria-pressed and the toggled look. Keep the label the same in both states. */
pressed?: boolean;
/** Native button type. 'button' by default, so it never submits a form by accident. */
type?: 'button' | 'submit' | 'reset';
/** Native disabled. */
disabled?: boolean;
/** Click handler. */
onclick?: (event: MouseEvent) => void;
/** Extra classes on the outer wrapper, for placement (margins, grid placement). */
class?: string;
}
let {
label,
icon,
variant = 'ghost',
size = 'md',
shape = 'square',
showHint = true,
pressed,
type = 'button',
disabled = false,
onclick,
class: className,
...rest
}: Props = $props();
/* An unnamed icon button is invisible to assistive technology, so a blank label is a bug. */
const name = $derived.by(() => {
if (!label?.trim()) throw new Error('IconButton: label is required and must not be blank.');
return label;
});
const uid = $props.id();
/* Each instance anchors its own hint, so two buttons never share an anchor name. */
const anchor = `--icon-button-${uid}`;
/* Escape hides the hint until the pointer leaves or focus moves on (WCAG 1.4.13). */
let dismissed = $state(false);
type ButtonEvent<E extends Event> = E & { currentTarget: EventTarget & HTMLButtonElement };
function handleKeydown(event: ButtonEvent<KeyboardEvent>) {
if (event.key === 'Escape') dismissed = true;
rest.onkeydown?.(event);
}
function handleFocusout(event: ButtonEvent<FocusEvent>) {
dismissed = false;
rest.onfocusout?.(event);
}
/*
* While the pointer is over the button or its hint, Escape anywhere hides the hint, so a
* hover hint can be dismissed while focus is in another field. The key is not stopped.
*/
let wrapper = $state<HTMLElement>();
let hovering = $state(false);
$effect(() => {
const el = wrapper;
if (!el) return;
const enter = () => (hovering = true);
const leave = () => {
hovering = false;
dismissed = false;
};
el.addEventListener('pointerenter', enter);
el.addEventListener('pointerleave', leave);
return () => {
el.removeEventListener('pointerenter', enter);
el.removeEventListener('pointerleave', leave);
};
});
$effect(() => {
if (!showHint || !hovering) return;
const dismiss = (event: KeyboardEvent) => {
if (event.key === 'Escape') dismissed = true;
};
window.addEventListener('keydown', dismiss);
return () => window.removeEventListener('keydown', dismiss);
});
</script>
<span
bind:this={wrapper}
class={['icon-button inline-flex shrink-0 align-middle', className]}
data-hint={showHint ? '' : undefined}
data-dismissed={dismissed ? '' : undefined}
>
<button
{...rest}
{type}
{disabled}
aria-label={name}
aria-pressed={pressed === undefined ? undefined : pressed ? 'true' : 'false'}
data-variant={variant}
class={[
'icon-button__button inline-grid shrink-0 cursor-pointer place-items-center select-none',
'bg-(--_fill) text-(--_icon) shadow-(--_edge) hover:bg-(--_fill-hover) hover:text-(--_icon-hover)',
'transition-[color,background-color,box-shadow,scale] duration-150 ease-[cubic-bezier(.2,0,0,1)] active:scale-(--_press) active:duration-[80ms] motion-reduce:transition-[color,background-color,box-shadow] motion-reduce:active:scale-100',
'outline-offset-2 outline-(--_ring) focus-visible:outline-2',
'disabled:cursor-not-allowed disabled:opacity-50',
shape === 'round' ? 'rounded-full' : size === 'sm' ? 'rounded-md' : 'rounded-lg',
size === 'sm' && 'size-8 pointer-coarse:size-11',
size === 'md' && 'size-10 pointer-coarse:size-11',
size === 'lg' && 'size-12'
]}
style:anchor-name={anchor}
onclick={(event) => onclick?.(event)}
onkeydown={handleKeydown}
onfocusout={handleFocusout}
>
<span
class={[
'grid place-items-center [&_svg]:size-full',
size === 'sm' ? 'size-4' : size === 'lg' ? 'size-6' : 'size-5'
]}
aria-hidden="true"
>
{@render icon()}
</span>
</button>
{#if showHint}
<!-- The name already comes from aria-label, so the hint is hidden from the tree. -->
<span
class="icon-button__hint pointer-events-none w-max max-w-60 rounded-md bg-(--_ink) px-2 py-1 text-center text-xs leading-4 font-medium text-balance text-(--_surface) shadow-(--_shadow-hint)"
style:position-anchor={anchor}
aria-hidden="true">{name}</span
>
{/if}
</span>
<style>
/* Public tokens: set --icon-button-* on the button or any ancestor to retone it. */
.icon-button {
--_accent: var(--icon-button-accent, #1d4ed8);
--_on-accent: var(--icon-button-on-accent, #ffffff);
--_ink: var(--icon-button-ink, #18181b);
--_muted: var(--icon-button-muted, #52525b);
--_hairline: var(--icon-button-hairline, rgb(0 0 0 / 0.12));
--_surface: var(--icon-button-surface, #ffffff);
--_ring: var(--icon-button-ring, #1d4ed8);
/* Formulas: the popover elevation from DESIGN §3.5, and the selected tint. */
--_shadow-hint:
0 0 0 1px rgb(0 0 0 / 0.05), 0 4px 6px -1px rgb(0 0 0 / 0.07),
0 10px 15px -3px rgb(0 0 0 / 0.05);
--_tint: color-mix(in oklab, var(--_accent) 10%, transparent);
}
/*
* Variants fill these in; the utilities on the button only ever read them. Hover moves each
* fill one step toward its icon colour, so it follows any accent.
*/
.icon-button__button {
--_press: 0.96;
}
.icon-button__button[data-variant='primary'] {
--_fill: var(--_accent);
--_fill-hover: color-mix(in oklab, var(--_accent) 88%, var(--_on-accent));
--_icon: var(--_on-accent);
--_icon-hover: var(--_on-accent);
--_edge: inset 0 1px 0 rgb(255 255 255 / 0.12), 0 1px 2px rgb(0 0 0 / 0.08);
}
.icon-button__button[data-variant='secondary'] {
--_fill: var(--_surface);
--_fill-hover: color-mix(in oklab, var(--_surface) 95%, var(--_ink));
--_icon: var(--_ink);
--_icon-hover: var(--_ink);
/* Inset, so the secondary is exactly as large as a filled button beside it. */
--_edge: inset 0 0 0 1px var(--_hairline), 0 1px 2px rgb(0 0 0 / 0.05);
}
.icon-button__button[data-variant='ghost'] {
--_fill: transparent;
--_fill-hover: color-mix(in oklab, var(--_ink) 6%, transparent);
--_icon: var(--_muted);
--_icon-hover: var(--_ink);
--_edge: 0 0 #0000;
}
/*
* Toggles. Ghost and secondary take the accent tint and an accent icon when on. A primary
* toggle is tonal when off and filled when on, so "on" is always the heavier state.
*/
.icon-button__button[data-variant='ghost'][aria-pressed='true'],
.icon-button__button[data-variant='secondary'][aria-pressed='true'] {
--_fill: var(--_tint);
--_fill-hover: color-mix(in oklab, var(--_accent) 16%, transparent);
--_icon: var(--_accent);
--_icon-hover: var(--_accent);
--_edge: inset 0 0 0 1px color-mix(in oklab, var(--_accent) 24%, transparent);
}
.icon-button__button[data-variant='primary'][aria-pressed='false'] {
--_fill: var(--_tint);
--_fill-hover: color-mix(in oklab, var(--_accent) 16%, transparent);
--_icon: var(--_accent);
--_icon-hover: var(--_accent);
--_edge: 0 0 #0000;
}
/* Disabled buttons neither change tone on hover nor press, toggles included. */
.icon-button .icon-button__button[data-variant]:disabled {
--_fill-hover: var(--_fill);
--_icon-hover: var(--_icon);
--_press: 1;
}
/*
* The hint. Positioning has no utility spelling. The baseline sits above the button,
* centred; physical left is safe here because centring with a -50% translate is the same
* in both directions.
*/
.icon-button[data-hint] {
position: relative;
}
.icon-button__hint {
position: absolute;
bottom: 100%;
left: 50%;
translate: -50% 0;
margin-block-end: 8px;
z-index: 50;
visibility: hidden;
opacity: 0;
/* Close: quick, with the exit curve; visibility flips once the fade has finished. */
transition:
opacity 120ms cubic-bezier(0.4, 0, 1, 1),
visibility 0s linear 120ms;
}
/*
* With anchor positioning the hint is fixed, so an overflow-hidden ancestor cannot clip it,
* kept inside the viewport, and flipped below when there is no room above.
*/
@supports (anchor-name: --icon-button) and (position-area: block-start) and
(position-try-fallbacks: flip-block) {
.icon-button__hint {
position: fixed;
inset: auto;
translate: none;
position-area: block-start;
position-try-fallbacks: flip-block;
/* 8 px from the button, and at least 8 px from either viewport edge. */
margin: 8px;
}
}
/* An invisible bridge across the gap, so the pointer can move onto the hint (WCAG 1.4.13). */
.icon-button__hint::before {
content: '';
position: absolute;
inset-block: -8px;
inset-inline: 0;
}
/* Hover shows the hint after a short delay, so sweeping across a toolbar stays quiet. */
@media (hover: hover) {
.icon-button[data-hint]:not([data-dismissed]):hover .icon-button__hint {
visibility: visible;
opacity: 1;
pointer-events: auto;
transition:
opacity 150ms cubic-bezier(0.16, 1, 0.3, 1) 500ms,
visibility 0s linear 500ms;
}
}
/* Keyboard focus shows it at once. */
.icon-button[data-hint]:not([data-dismissed]):has(> .icon-button__button:focus-visible)
.icon-button__hint {
visibility: visible;
opacity: 1;
transition: opacity 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
</style>
Usage#
On this pagePass a label that names the action and an icon snippet holding an inline SVG. It renders a native button with the label as its aria-label, and shows the same label as a small hint on hover and keyboard focus. Set pressed to make it a toggle. It does not ship icons, show rich or interactive tooltips, render links, or show a pending state; use a labelled button for those.
- Suggested location
src/lib/components/icon-button-01
Limitations
- The hint flips below through CSS anchor positioning. Browsers without it (or without position-area and position-try-fallbacks) place the hint above the button, absolutely positioned: it does not flip, an overflow-hidden ancestor can clip it, and a long hint beside a viewport edge can run past it.
- With anchor positioning the hint is position: fixed with z-index 50, so overflow cannot clip it, but it stays inside its ancestors' stacking contexts: a toolbar under a higher sibling can have its hint covered, and an ancestor with a transform, filter or contain: paint becomes its containing block instead of the viewport.
- Escape hides the hint only after hydration: on the focused button, or anywhere while the pointer is over the button or its hint. It does not stop the key event, so a dialog that closes on Escape still closes.
- The button sits inside an inline-flex wrapper that holds the hint; class goes on that wrapper, and other attributes go on the button.
- The label is shown verbatim in the hint. Keep it short and name the action; a pressed toggle keeps the same label in both states.
- Icons are sized to half the square (16, 20 or 24 px) through a descendant svg selector; an icon that is not an <svg> needs its own size.
- A blank label throws when the component is created, so an unnamed button fails in development instead of shipping.
Example
<script lang="ts">
import IconButton from '$lib/components/icon-button-01/IconButton.svelte';
let bookmarked = $state(false);
</script>
{#snippet bookmark()}
<svg viewBox="0 0 24 24" fill={bookmarked ? 'currentColor' : 'none'}>
<path d="M6.5 4.5h11v15L12 15.5l-5.5 4v-15Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
</svg>
{/snippet}
{#snippet close()}
<svg viewBox="0 0 24 24" fill="none">
<path d="M6.5 6.5l11 11M17.5 6.5l-11 11" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
{/snippet}
<div class="flex items-center gap-2">
<IconButton
label="Bookmark card"
icon={bookmark}
pressed={bookmarked}
onclick={() => (bookmarked = !bookmarked)}
/>
<IconButton label="Close panel" icon={close} variant="secondary" />
</div>Icon button#
A compact action with no visible text: close, edit, bookmark, more options. The label prop is
required, and a blank label throws. It becomes the button's aria-label and the text of a small hint that appears above
the button on hover and keyboard focus, so the button never ships without a name.
| Variant | Use it for |
|---|---|
ghost |
Toolbar actions inside cards, panels and rows. The default. |
secondary |
A close or dismiss that needs an edge, or a button on a busy fill. |
primary |
The one action the view exists for, such as sending a comment. |
Icons#
Pass an inline SVG on a 24 px viewBox with currentColor strokes. The button sizes it to half the
square: 16 px at sm, 20 px at md, 24 px at lg. A stroke of about 1.8 on a 24 px viewBox
renders near 1.5 px at md. The component does not ship an icon set.
The hint#
- Hover shows it after 500 ms, so sweeping across a toolbar stays quiet. Keyboard focus shows it at once.
- It sits above the button and flips below when there is no room, through CSS anchor positioning. Browsers without anchor positioning place it above, absolutely, and do not flip it.
- Escape hides it without moving focus, until the pointer leaves or focus moves on. It works on the focused button and anywhere while the pointer is over the button. The key event is not stopped, so a dialog that closes on Escape still closes.
- Hover shows it only on devices with hover, and a tap never shows it. Keyboard focus does, including on a tablet with a keyboard.
- With anchor positioning the hint is fixed with
z-index: 50, sooverflow: hiddencannot clip it, but an ancestor's stacking context still can cover it. - It is
aria-hidden: the name comes fromaria-label, so it is announced once.
Set showHint={false} when the label is already visible nearby.
Toggles#
Set pressed to render aria-pressed. Keep the label the same in both states ("Bookmark card",
not "Remove bookmark") and swap to a filled icon when on, so the state is not carried by colour
alone.
<IconButton
label="Bookmark card"
icon={bookmarked ? bookmarkFilled : bookmark}
pressed={bookmarked}
onclick={() => (bookmarked = !bookmarked)}
/>Retoning#
Every colour is a CSS variable. On a #09090b band:
.band {
background: #09090b;
--icon-button-accent: #fafafa;
--icon-button-on-accent: #09090b;
--icon-button-ring: #fafafa;
--icon-button-ink: #fafafa;
--icon-button-muted: #a1a1aa;
--icon-button-surface: #18181b;
--icon-button-hairline: rgb(255 255 255 / 0.12);
}The hint uses ink as its fill and surface as its text, so it turns into a light chip there.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | The accessible name (aria-label) and the hint's text. Must not be blank: a blank label throws. Name the action, 'Close panel' rather than 'Close', and keep it the same while pressed changes. |
icon | Snippet | Yes | None | The icon, usually an inline SVG using currentColor. Rendered in an aria-hidden box sized to half the button. |
variant | 'primary' | 'secondary' | 'ghost' | No | 'ghost' | Visual priority: an accent fill, a surface with a hairline, or no fill until hover. Same names as action-button-01. |
size | 'sm' | 'md' | 'lg' | No | 'md' | 32, 40 or 48 px square with a 16, 20 or 24 px icon. sm and md grow to 44 px on coarse pointers. |
shape | 'square' | 'round' | No | 'square' | A rounded square (6 px radius at sm, 8 px at md and lg) or a circle. |
showHint | boolean | No | true | Show the label as a hint above the button on hover (after 500 ms, devices with hover only) and on keyboard focus (at once). A tap never shows it. |
pressed | boolean | No | None | When set, renders aria-pressed and the toggled look: ghost and secondary take an accent tint when on, a primary is tonal when off and filled when on. Leave undefined for a plain action. |
type | 'button' | 'submit' | 'reset' | No | 'button' | Native button type, so it never submits a form by accident. |
disabled | boolean | No | false | Native disabled. The hint still shows on hover. |
onclick | (event: MouseEvent) => void | No | None | Click handler. |
class | string | No | None | Extra classes on the outer wrapper, for placement such as margins or grid placement. |
Customization#
On this pageChange the icon, label, priority, size and shape through props, and retone every variant, the hint and the focus ring through seven --icon-button-* CSS variables. Hover and toggled tones are mixed from the accent and ink, so they follow any colour you set.
- Priority: icon buttons usually sit inside other components, so ghost is the default. Use secondary for a close or dismiss that needs an edge, and primary only for the one action the view exists for, such as sending a comment.
- Accent:
--icon-button-accentfills primary buttons and tints pressed toggles;--icon-button-on-accentis the primary icon. Keep the pair above 3:1 for the icon, with margin. - Ring:
--icon-button-ringcolours the focus ring (near-black by default). Set it with the accent, and keep it at 3:1 against the page. - Neutrals:
--icon-button-inkis the secondary icon, the ghost hover icon and the hint's fill;--icon-button-surfaceis the secondary fill and the hint's text;--icon-button-mutedis the ghost icon at rest;--icon-button-hairlineis the secondary edge. - Worked retone for a
#09090bband:--icon-button-accent:#fafafa;--icon-button-on-accent:#09090b;--icon-button-ring:#fafafa;--icon-button-ink:#fafafa;--icon-button-muted:#a1a1aa;--icon-button-surface:#18181b;--icon-button-hairline: rgb(255 255 255 / 0.12). The hint turns into a light chip with dark text. - Icons: pass an inline SVG with
currentColorstrokes on a 24 pxviewBox. The button sizes it; a stroke of about 1.8 renders near 1.5 px at md. For a toggle, swap to a filled icon when pressed so the state is not carried by colour alone. - Hint: set
showHint={false}where the label is already visible nearby, such as a close button beside a dialog title that names it. - Layout: place several in a flex row with
gap-2for a toolbar, so the focus ring (two pixels, two pixels out) never touches the neighbouring button; pass class for margins.
Public CSS variables
| Variable | Token |
|---|---|
--icon-button-accent | accent |
--icon-button-on-accent | onAccent |
--icon-button-ink | ink |
--icon-button-muted | muted |
--icon-button-hairline | hairline |
--icon-button-surface | surface |
--icon-button-ring | ring |
Accessibility#
On this page- Follows the WAI-ARIA APG button pattern with a native <button>, so Enter and Space activate it with no extra handlers. type defaults to 'button'.
- label is required by the Props type, a blank label throws, and the label becomes
aria-label. The hint repeats it visually and isaria-hidden, so the name is announced once; no title attribute is rendered, so there is no second native tooltip. - The icon box is
aria-hidden, which hides it from the tree but does not stop focus. Pass a plain SVG (no tabindex, no <a> inside) so nothing in the button can take focus. - With pressed set,
aria-pressedreflects it. Keep the label constant ('Bookmark card', not 'Remove bookmark'); assistive technology announces the state. - The hint appears on keyboard focus at once and, on devices with hover, after 500 ms of hover. It stays while the pointer moves onto it, and Escape hides it without moving focus, whether focus is on the button or elsewhere while the pointer is over it (WCAG 1.4.13). A tap never shows it; the name still applies.
- Targets are 32, 40 and 48 px, above the 24 px of WCAG 2.2 2.5.8; sm and md grow to 44 px on coarse pointers.
- Focus shows a two-pixel ring in
--icon-button-ring, two pixels outside the button's radius. Keep it at 3:1 against the page. - The icon and the name together must explain the action; the hint is a convenience, not the only way to learn the purpose.
- Disabled uses the native attribute, which removes the button from the tab order. Explain nearby why the action is unavailable.
Release details#
On this page- Integration
- Local interaction
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 1 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.