# 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.

- ID: `cmp_action_button_01`
- Slug: `action-button-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: control
- Primary category: `buttons`
- Detail page: https://pagesugar.com/components/action-button-01
- Preview: https://pagesugar.com/preview/action-button-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-a3f74d32a47afd9dc1f5b96b1e0ed3862e04dde8620bf1f067385c1219304523` |
| `blue` | Blue accent | no | `sha256-a442f22124e1f659a36a612a432ff9b0e072d4b45c42b2b72a62298dcff8a4e7` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/action-button-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass 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.

Required props: `children`

```svelte
<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>
```

Limitations:

- The pending guard (swallowing clicks) needs hydration. Before hydration a pending submit button can still submit its form.
- The live region that announces pendingLabel is 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-busy and aria-disabled are 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, pendingLabel is shown on one line and clipped to the label's width; keep it shorter than the label ("Saving" for "Save changes").

## Usage guide

### 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.

```svelte
<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:

```css
.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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `children` | `Snippet` | yes |  | 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 |  | 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 |  | Leading icon, treated as decorative. The spinner takes its place while pending. |
| `trailingIcon` | `Snippet` | no |  | Trailing icon, treated as decorative: a chevron, an arrow or an external-link mark. |
| `onclick` | `(event: MouseEvent) => void` | no |  | Click handler. Not called while pending or while a link is disabled. |
| `class` | `string` | no |  | Extra classes for placement, such as margins or grid placement. |

## Customization

Change 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-accent fills primary buttons and colours the focus ring; --action-button-on-accent is 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-danger and --action-button-on-danger colour destructive buttons and their focus ring. The default is red-700 on white (6.5:1).
- Neutrals: --action-button-ink is the label of secondary and ghost buttons, --action-button-muted their icons, --action-button-surface the secondary fill and --action-button-hairline its one-pixel edge.
- Worked retone for a #09090b band: --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-danger so 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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--action-button-accent` |
| `onAccent` | `--action-button-on-accent` |
| `danger` | `--action-button-danger` |
| `onDanger` | `--action-button-on-danger` |
| `ink` | `--action-button-ink` |
| `muted` | `--action-button-muted` |
| `hairline` | `--action-button-hairline` |
| `surface` | `--action-button-surface` |

## Accessibility

- 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-busy and aria-disabled instead of the disabled attribute, so keyboard focus stays on the button. pendingLabel is 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=link and sets aria-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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `ActionButton.svelte`
- Suggested directory: `src/lib/components/action-button-01`
- Files: 1
- Artifact digest: `sha256-a3f74d32a47afd9dc1f5b96b1e0ed3862e04dde8620bf1f067385c1219304523`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `ActionButton.svelte`

Role: entry · 9308 bytes · SHA-256 `48899e384e546c31a62de1d70acb7136d08b0eb77445acbb23b9966f0446eaa2`

```svelte
<!--
	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, #18181b);
		--_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>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-a3f74d32a47afd9dc1f5b96b1e0ed3862e04dde8620bf1f067385c1219304523`
- Entry: `ActionButton.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_action_button_01/1.0.0/neutral/sha256-a3f74d32a47afd9dc1f5b96b1e0ed3862e04dde8620bf1f067385c1219304523/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_action_button_01/1.0.0/neutral/sha256-a3f74d32a47afd9dc1f5b96b1e0ed3862e04dde8620bf1f067385c1219304523/bundle.zip (6909 bytes, sha256 `b64860ab8766507b15305ab2b200c758ed9deb96185000b1003739ec2f43c966`)

Files:

- `ActionButton.svelte` (entry, 9308 bytes): https://pagesugar.com/artifacts/cmp_action_button_01/1.0.0/neutral/sha256-a3f74d32a47afd9dc1f5b96b1e0ed3862e04dde8620bf1f067385c1219304523/source/ActionButton.svelte

### Blue accent (`blue`)

- Artifact digest: `sha256-a442f22124e1f659a36a612a432ff9b0e072d4b45c42b2b72a62298dcff8a4e7`
- Entry: `ActionButton.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_action_button_01/1.0.0/blue/sha256-a442f22124e1f659a36a612a432ff9b0e072d4b45c42b2b72a62298dcff8a4e7/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_action_button_01/1.0.0/blue/sha256-a442f22124e1f659a36a612a432ff9b0e072d4b45c42b2b72a62298dcff8a4e7/bundle.zip (6920 bytes, sha256 `0fdd2b93d4ac1bdb1e730d2c6efb500be0cfe5b619f29dfc0908e0c61aed6bf5`)

Files:

- `ActionButton.svelte` (entry, 9308 bytes): https://pagesugar.com/artifacts/cmp_action_button_01/1.0.0/blue/sha256-a442f22124e1f659a36a612a432ff9b0e072d4b45c42b2b72a62298dcff8a4e7/source/ActionButton.svelte
