Action button
A labelled action button in primary, secondary, ghost and destructive priorities and three sizes, with optional icons and a pending state that holds its width and focus. Renders a <button>, or an <a> when given href.
cmp_action_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_action_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-a442f22124e1f659a36a612a432ff9b0e072d4b45c42b2b72a62298dcff8a4e7
<!--
A labelled action button: primary, secondary, ghost or destructive, in three sizes. It renders
a native <button>, or an <a> styled the same way when href is set. While pending the spinner
takes the leading icon's place (or sits over a transparent label when there is none), so the
button keeps its exact box and its focus, and the pending label is announced once.
-->
<script lang="ts" module>
export type ActionButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive';
export type ActionButtonSize = 'sm' | 'md' | 'lg';
</script>
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
type Passthrough = Omit<
HTMLButtonAttributes & HTMLAnchorAttributes,
'type' | 'href' | 'disabled' | 'children' | 'onclick' | 'class' | 'aria-disabled' | 'aria-busy'
>;
interface Props extends Passthrough {
/** Visual priority. One primary per view; destructive for actions that lose data. */
variant?: ActionButtonVariant;
/** sm 32 px, md 36 px, lg 40 px tall at rest; every size grows to 44 px on touch screens. */
size?: ActionButtonSize;
/** Native button type. 'button' by default, so it never submits a form by accident. */
type?: 'button' | 'submit' | 'reset';
/** Renders an <a> instead of a <button>. Removed while disabled. */
href?: string;
/** Native disabled on a button; aria-disabled and no href on a link. */
disabled?: boolean;
/** Shows the spinner, sets aria-busy and swallows clicks, without dropping focus. */
pending?: boolean;
/** Announced once, politely, when pending starts. */
pendingLabel?: string;
/** Fill the width of the parent instead of hugging the label. */
block?: boolean;
/** Leading icon, decorative. Size the SVG with the button: it is set to the size's icon box. */
icon?: Snippet;
/** Trailing icon, decorative: a chevron, an arrow, an external-link mark. */
trailingIcon?: Snippet;
/** The label. Name the action: "Delete project", not "OK". */
children: Snippet;
/** Not called while pending or disabled. */
onclick?: (event: MouseEvent) => void;
/** Extra classes for placement (margins, grid placement). */
class?: string;
}
let {
variant = 'primary',
size = 'md',
type = 'button',
href,
disabled = false,
pending = false,
pendingLabel = 'Working',
block = false,
icon,
trailingIcon,
children,
onclick,
class: className,
...rest
}: Props = $props();
const isLink = $derived(href !== undefined);
/** Without a leading icon to swap, the spinner and pending text sit over the whole label row. */
const overlay = $derived(pending && !icon);
/** A disabled or pending link has no destination, so no click of any button can follow it. */
const inert = $derived(isLink && (disabled || pending));
/*
* The live region is filled only after hydration, so a button that starts out pending still
* produces a change for assistive technology to announce.
*/
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
function handleClick(event: MouseEvent) {
if (pending || (isLink && disabled)) {
// Swallow the click (and a form's implicit submission) but keep focus where it is.
event.preventDefault();
event.stopImmediatePropagation();
return;
}
onclick?.(event);
}
const iconBox = $derived(
size === 'sm'
? 'h-lh w-3.5 [&_svg]:size-3.5'
: size === 'lg'
? 'h-lh w-[18px] [&_svg]:size-[18px]'
: 'h-lh w-4 [&_svg]:size-4'
);
</script>
<svelte:element
this={isLink ? 'a' : 'button'}
{...rest}
type={isLink ? undefined : type}
href={inert ? undefined : href}
role={inert ? 'link' : rest.role}
tabindex={isLink && pending && !disabled ? 0 : rest.tabindex}
disabled={!isLink && disabled ? true : undefined}
aria-disabled={(isLink && disabled) || pending ? 'true' : undefined}
aria-busy={pending ? 'true' : undefined}
data-variant={variant}
data-disabled={disabled ? '' : undefined}
class={[
'action-button relative max-w-full cursor-pointer items-center justify-center py-2 font-medium no-underline select-none',
'bg-[var(--_fill)] text-[var(--_label)] shadow-[var(--_edge)] hover:bg-[var(--_fill-hover)]',
'transition-[background-color,box-shadow,scale] duration-150 ease-[cubic-bezier(.2,0,0,1)] active:scale-(--_press) active:duration-[80ms] motion-reduce:transition-[background-color,box-shadow] motion-reduce:active:scale-100',
'outline-offset-2 outline-[var(--_ring)] focus-visible:outline-2',
'aria-busy:cursor-progress data-disabled:cursor-not-allowed data-disabled:opacity-50',
'pointer-coarse:min-h-11',
block ? 'flex w-full' : 'inline-flex',
size === 'sm' && 'min-h-8 rounded-md px-3 text-[13px] leading-4',
size === 'md' && 'min-h-9 rounded-lg px-4 text-sm leading-5',
size === 'lg' && 'min-h-10 rounded-lg px-[18px] text-[15px] leading-6',
className
]}
onclick={handleClick}
>
<!-- One row that aligns to the label's first line, so a wrapped label keeps its icons level.
sm's 6 px gap and lg's 18 px padding are DESIGN §3.8's button table, which outranks the
4 px grid here. -->
<span class={['flex min-w-0 items-start', size === 'sm' ? 'gap-1.5' : 'gap-2']}>
{#if icon}
<span
class={['grid shrink-0 place-items-center text-[var(--_icon)]', iconBox]}
aria-hidden="true"
>
{#if pending}
{@render spinner()}
{:else}
{@render icon()}
{/if}
</span>
{/if}
<!-- While pending the label turns transparent but keeps its box and the button's name; the
visible text becomes pendingLabel, clipped to that box so the width never moves. -->
<span class="relative min-w-0">
<span class={['block text-start text-pretty break-words', pending && 'opacity-0']}>
{@render children()}
</span>
{#if pending && !overlay}
<span class="absolute inset-0 truncate text-center" aria-hidden="true">{pendingLabel}…</span
>
{/if}
</span>
{#if trailingIcon}
<span
class={[
'grid shrink-0 place-items-center text-[var(--_icon)]',
iconBox,
overlay && 'opacity-0'
]}
aria-hidden="true"
>
{@render trailingIcon()}
</span>
{/if}
</span>
{#if overlay}
<span
class={[
'absolute inset-0 flex items-center justify-center',
size === 'sm' ? 'gap-1.5 px-3' : size === 'lg' ? 'gap-2 px-[18px]' : 'gap-2 px-4'
]}
aria-hidden="true"
>
<span class={['grid shrink-0 place-items-center', iconBox]}>{@render spinner()}</span>
<span class="min-w-0 truncate">{pendingLabel}…</span>
</span>
{/if}
</svelte:element>
<!-- Outside the button, so the button's name stays its label; filled only while pending. -->
<span class="sr-only" role="status" aria-live="polite"
>{hydrated && pending ? pendingLabel : ''}</span
>
{#snippet spinner()}
<svg
class="animate-spin motion-reduce:animate-pulse"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6.25"
stroke="currentColor"
stroke-opacity="0.25"
stroke-width="1.75"
/>
<path
d="M14.25 8A6.25 6.25 0 0 0 8 1.75"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
</svg>
{/snippet}
<style>
/* Public tokens: set --action-button-* on the button or any ancestor to retone it. */
.action-button {
--_accent: var(--action-button-accent, #1d4ed8);
--_on-accent: var(--action-button-on-accent, #ffffff);
--_danger: var(--action-button-danger, #b91c1c);
--_on-danger: var(--action-button-on-danger, #ffffff);
--_ink: var(--action-button-ink, #18181b);
--_muted: var(--action-button-muted, #52525b);
--_hairline: var(--action-button-hairline, rgb(0 0 0 / 0.12));
--_surface: var(--action-button-surface, #ffffff);
/* Each variant fills these in; the classes above only ever read them. */
--_press: 0.98;
--_ring: var(--_accent);
--_icon: currentColor;
}
/* Hover moves the fill one step toward its own label colour, so it works for any accent. */
.action-button[data-variant='primary'] {
--_fill: var(--_accent);
--_fill-hover: color-mix(in oklab, var(--_accent) 88%, var(--_on-accent));
--_label: var(--_on-accent);
--_edge: inset 0 1px 0 rgb(255 255 255 / 0.12), 0 1px 2px rgb(0 0 0 / 0.08);
}
.action-button[data-variant='secondary'] {
--_fill: var(--_surface);
--_fill-hover: color-mix(in oklab, var(--_surface) 95%, var(--_ink));
--_label: var(--_ink);
--_icon: var(--_muted);
/* The hairline is 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);
}
.action-button[data-variant='ghost'] {
--_fill: transparent;
--_fill-hover: color-mix(in oklab, var(--_ink) 6%, transparent);
--_label: var(--_ink);
--_icon: var(--_muted);
--_edge: 0 0 #0000;
}
.action-button[data-variant='destructive'] {
--_fill: var(--_danger);
--_fill-hover: color-mix(in oklab, var(--_danger) 88%, var(--_on-danger));
--_label: var(--_on-danger);
--_ring: var(--_danger);
--_edge: inset 0 1px 0 rgb(255 255 255 / 0.12), 0 1px 2px rgb(0 0 0 / 0.08);
}
/* Disabled and pending buttons neither change tone on hover nor press. */
.action-button[data-disabled],
.action-button[aria-busy='true'] {
--_fill-hover: var(--_fill);
--_press: 1;
}
</style>
Usage#
On this pagePass the label as children and pick a variant and size. It renders a native button (or a link when href is set), so Enter, Space and form submission work with no script. Set pending while your own request runs: the button keeps its width and focus, shows a spinner and pendingLabel in place of the label, announces it once and ignores clicks. It does not debounce or deduplicate requests, confirm destructive actions, or render icon-only buttons.
- Suggested location
src/lib/components/action-button-01- Required props
children
Limitations
- The pending guard (swallowing clicks) needs hydration. Before hydration a pending submit button can still submit its form.
- The live region that announces
pendingLabelis a visually hidden sibling of the button, so the component renders two elements; keep that in mind with :last-child or sibling selectors. - Icons are sized to the button (14, 16 or 18 px) through a descendant svg selector; an icon that is not an <svg> needs its own size.
- A disabled link has no href and is left out of the tab order, like a disabled button. Explain nearby why the action is unavailable.
- Icon-only buttons need an accessible name the label would give; use a dedicated icon button for those.
aria-busyandaria-disabledare owned by the component (from pending and disabled) and are not accepted as attributes; role and tabindex pass through except where a disabled or pending link needs them.- While pending,
pendingLabelis shown on one line and clipped to the label's width; keep it shorter than the label ("Saving" for "Save changes").
Example
<script lang="ts">
import ActionButton from '$lib/components/action-button-01/ActionButton.svelte';
let saving = $state(false);
async function save() {
saving = true;
try {
await fetch('/api/board', { method: 'PATCH' });
} finally {
saving = false;
}
}
</script>
<div class="flex justify-end gap-2">
<ActionButton variant="ghost">Discard</ActionButton>
<ActionButton pending={saving} pendingLabel="Saving" onclick={save}>Save changes</ActionButton>
</div>Action button#
One labelled button for every action in a view, in four priorities:
| Variant | Use it for |
|---|---|
primary |
The one action the view exists for. One per view. |
secondary |
A second, useful action beside the primary. |
ghost |
Dismissive or tertiary actions: Cancel, Discard, Filters. |
destructive |
Actions that remove data. Name the action in the label. |
Pending#
Set pending while your own request runs and clear it when it settles. The button keeps its
width and keyboard focus, ignores clicks (including a form's implicit submission), sets
aria-busy, and writes pendingLabel once into a polite live region beside it. The label turns
transparent (keeping its space and the button's accessible name) and pendingLabel… takes its
place: with a leading icon the spinner takes the icon's slot, without one the spinner and the
text are centred over the label row. Keep pendingLabel shorter than the label; it is clipped to
one line in the label's width.
<ActionButton type="submit" pending={saving} pendingLabel="Saving">Save changes</ActionButton>The component never debounces or deduplicates requests, and it does not show a confirmation before a destructive action. Put a destructive button in your own confirmation dialog, where it should be the only filled button.
Links#
href renders an <a> with the same shape. Links keep link behaviour: Enter follows them and
Space scrolls. While disabled, the link loses its href, keeps role="link" and sets
aria-disabled="true".
Icons#
Pass icon and trailingIcon as snippets holding an inline SVG. The button sizes the SVG to
14, 16 or 18 px for sm, md and lg, and centres it on the label's first line, so a wrapped
label keeps its icon level with the first word. Icons are decorative (aria-hidden); the label
carries the meaning. Flip directional arrows yourself under :dir(rtl).
Retoning#
Every colour is a CSS variable, so the same button fits a white SaaS page, a cream bakery page
or a dark band. On a #09090b band:
.band {
background: #09090b;
--action-button-accent: #fafafa;
--action-button-on-accent: #09090b;
--action-button-ink: #fafafa;
--action-button-muted: #a1a1aa;
--action-button-surface: #18181b;
--action-button-hairline: rgb(255 255 255 / 0.12);
}The default danger colour keeps its focus ring at 3:1 on #09090b. On a lighter dark band, such as
#18181b, set --action-button-danger to a lighter red and recheck its label contrast.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
children | Snippet | Yes | None | The label. Name the action ("Delete board", not "OK"). |
variant | 'primary' | 'secondary' | 'ghost' | 'destructive' | No | 'primary' | Visual priority. Use one primary per view; destructive for actions that remove data. |
size | 'sm' | 'md' | 'lg' | No | 'md' | 32, 36 or 40 px tall at rest, with 13, 14 or 15 px labels. Every size grows to at least 44 px on coarse pointers. |
type | 'button' | 'submit' | 'reset' | No | 'button' | Native button type. Ignored when href is set. |
href | string | No | None | Renders an <a> with the same styling. Removed while disabled or pending, so no click (middle-click included) can follow it. |
disabled | boolean | No | false | Native disabled on a button; on a link, removes href and sets aria-disabled="true". |
pending | boolean | No | false | Replaces the label with a spinner and pendingLabel in the same box, sets aria-busy and aria-disabled, and ignores clicks without the disabled attribute, so focus stays on the button. A pending link keeps role=link and its place in the tab order but loses its href until pending ends. |
pendingLabel | string | No | 'Working' | Shown in place of the label while pending (with an ellipsis, clipped to the label's width) and written into a polite live region when pending starts, after hydration so a button that starts pending is announced too. For example 'Saving'. |
block | boolean | No | false | Fill the parent's width instead of hugging the label. |
icon | Snippet | No | None | Leading icon, treated as decorative. The spinner takes its place while pending. |
trailingIcon | Snippet | No | None | Trailing icon, treated as decorative: a chevron, an arrow or an external-link mark. |
onclick | (event: MouseEvent) => void | No | None | Click handler. Not called while pending or while a link is disabled. |
class | string | No | None | Extra classes for placement, such as margins or grid placement. |
Customization#
On this pageChange the label, icons and priority through props, and retone every variant through eight --action-button-* CSS variables. Hover tones are mixed from the fill and its label colour, so they follow any accent you set.
- Priority: use one primary per view. Secondary is for the second action, ghost for dismissive or tertiary ones (Cancel, Discard), destructive for actions that remove data. A destructive confirmation should be the only filled button in its dialog.
- Accent:
--action-button-accentfills primary buttons and colours the focus ring;--action-button-on-accentis their label. Keep the pair above 4.5:1 with some margin, because hover mixes 12% of the label colour into the fill. - Danger:
--action-button-dangerand--action-button-on-dangercolour destructive buttons and their focus ring. The default isred-700on white (6.5:1). - Neutrals:
--action-button-inkis the label of secondary and ghost buttons,--action-button-mutedtheir icons,--action-button-surfacethe secondary fill and--action-button-hairlineits one-pixel edge. - Worked retone for a
#09090bband:--action-button-accent:#fafafa;--action-button-on-accent:#09090b;--action-button-ink:#fafafa;--action-button-muted:#a1a1aa;--action-button-surface:#18181b;--action-button-hairline: rgb(255 255 255 / 0.12). On a lighter band such as#18181b, lighten--action-button-dangerso its focus ring keeps 3:1. - Sizes: sm for dense rows and tables, md for app UI, lg for marketing forms and cards. Heights come from min-h and a line height, so long labels wrap instead of clipping.
- Layout: set block for full-width buttons in narrow columns and on phones; pass class for margins. Put several buttons in a flex row with
gap-2. - Links: set href to render an <a>. Keep it for navigation; an action that changes data belongs on a button.
Public CSS variables
| Variable | Token |
|---|---|
--action-button-accent | accent |
--action-button-on-accent | onAccent |
--action-button-danger | danger |
--action-button-on-danger | onDanger |
--action-button-ink | ink |
--action-button-muted | muted |
--action-button-hairline | hairline |
--action-button-surface | surface |
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. With href it is a native link: Enter follows it and Space scrolls the page, as links do.
- type defaults to 'button', so a button inside a form submits only when you set type='submit'.
- Pending sets
aria-busyandaria-disabledinstead of the disabled attribute, so keyboard focus stays on the button.pendingLabelis written into a visually hidden polite live region beside the button once the page has hydrated, so the region exists before its text changes; the button's accessible name stays its label. - Focus ring: a two-pixel outline two pixels outside the button, so it touches the page rather than the fill. It is measured against the page (3:1 with the default accent and danger colours on white), not against the fill it surrounds.
- Disabled buttons use the native attribute. A disabled link has no href, keeps
role=linkand setsaria-disabled='true'. Both leave the tab order, so give the reason in nearby text. - Destructive buttons must name the action in the label; the red fill is a secondary cue.
- Focus shows a two-pixel ring in the accent (the danger colour on destructive buttons), two pixels outside the button's radius. Keep both tokens at 3:1 against the page.
- Icons and the spinner are
aria-hidden. Every size is at least 44 px tall on coarse pointers. Under reduced motion the press does not scale and the spinner pulses instead of turning.
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 · 30 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.