Skip to content
Download ZIP

Blue accent palette · 6.8 KB ZIP File receipt View as Markdown View code

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.
cmp_action_button_01 · version 1.0.0 · Blue accent palette
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
ActionButton.svelte Svelte · 9.1 KB Raw
<!--
	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>

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.

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 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").

Example

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>

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.

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
childrenSnippetYesNoneThe 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.
hrefstringNoNoneRenders an <a> with the same styling. Removed while disabled or pending, so no click (middle-click included) can follow it.
disabledbooleanNofalseNative disabled on a button; on a link, removes href and sets aria-disabled="true".
pendingbooleanNofalseReplaces 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.
pendingLabelstringNo'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'.
blockbooleanNofalseFill the parent's width instead of hugging the label.
iconSnippetNoNoneLeading icon, treated as decorative. The spinner takes its place while pending.
trailingIconSnippetNoNoneTrailing icon, treated as decorative: a chevron, an arrow or an external-link mark.
onclick(event: MouseEvent) => voidNoNoneClick handler. Not called while pending or while a link is disabled.
classstringNoNoneExtra classes for placement, such as margins or grid placement.

Customization#

On this page

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.

Public CSS variables

VariableToken
--action-button-accentaccent
--action-button-on-accentonAccent
--action-button-dangerdanger
--action-button-on-dangeronDanger
--action-button-inkink
--action-button-mutedmuted
--action-button-hairlinehairline
--action-button-surfacesurface

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

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.