For frontend developers with 1-2 years of experience who want a fast, state-driven button in Svelte. Covers state colors, disabled and loading states, accessibility (ARIA), testing, pitfalls, and runnable examples.
Target readers and prerequisites
- Frontend engineers familiar with JS/TS and new to Svelte or already using it.
- Developers who need a unified button style, state, and interaction in a project.
- Requirements: Node 18+, Svelte 5, package manager (npm/pnpm), can run
npm create svelte@latest.
Background / Motivation
- Buttons are high-frequency interactions, but style, state, and accessibility are often ignored.
- Dynamic class names without null protection lead to
undefinedor broken styles. - Accessibility (keyboard and ARIA) plus loading/disabled states are product-grade requirements.
- Consistency needs a centralized state-to-style mapping to avoid magic strings everywhere.
Core concepts
- State mapping: map business states to class strings via a function, not nested ternaries in templates.
- Optional chaining (
?.) and nullish coalescing (??): safely read backend fields and provide defaults. - ARIA and keyboard access:
aria-busy,aria-disabled,role,tabindexhelp screen readers and keyboard users. - Visual hierarchy: primary, secondary, ghost buttons.
Environment and dependencies
- Node 18+, Svelte 5
- UI utility classes: examples use Tailwind (replace with any styling system)
- Recommended commands:
npm create svelte@latest demo-buttons
cd demo-buttons
npm install
Practical steps
1) Centralize state-to-style mapping
// statusTone.ts
export function statusTone(status?: string) {
if (status === 'succeeded' || status === 'completed') {
return 'bg-emerald-600 hover:bg-emerald-700 text-white border border-emerald-600';
}
if (status === 'failed') {
return 'bg-rose-600 hover:bg-rose-700 text-white border border-rose-600';
}
if (status === 'processing' || status === 'pending') {
return 'bg-amber-500 hover:bg-amber-600 text-white border border-amber-500';
}
return 'bg-slate-200 text-slate-700 border border-slate-300';
}
Why: keep status-to-class logic centralized and maintainable; supports both completed and succeeded.
2) Safe values inside a Svelte component
<script lang="ts">
import { statusTone } from './statusTone';
export let status: string | undefined;
export let loading = false;
export let label = 'Submit';
</script>
<button
class={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition ${statusTone(status)}`}
aria-busy={loading}
aria-disabled={loading}
disabled={loading}
>
{#if loading}
<span class="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{/if}
{label ?? 'Submit'}
</button>
Notes:
label ?? 'Submit'provides a default label safely.aria-busy,aria-disabled, anddisabledstay in sync.
3) Optional chaining and nullish coalescing example
{#if detailStatus?.status ?? record.status}
<span class="text-xs text-slate-500">
Current status: {detailStatus?.status ?? record.status ?? 'pending'}
</span>
{/if}
?. avoids errors if detailStatus is undefined, ?? falls back to a default.
4) Keyboard and screen reader support
- For non-
<button>elements, add:role="button",tabindex="0",aria-label="...".- Handle
on:keydownfor Enter or Space.
- Sync loading/disabled state with
aria-busyandaria-disabled.
5) Common variants
- Primary: main action, high-contrast or brand color.
- Secondary: dark or outline style for secondary actions.
- Ghost: transparent background with border.
- Icon button: add
aria-labelfor screen readers.
6) Skeleton loading / disabled strategy
- Loading: show spinner, block double-submit; use
disabledandaria-busy. - Disabled: for permission/quota conditions, use weaker style like
opacity-60 cursor-not-allowed.
7) Events and error handling
- Wrap click: set loading optimistically, run async work, reset in
finally. - On error: show toast, and color with
statusTone('failed')if needed.
Runnable snippet
<script lang="ts">
import { statusTone } from './statusTone';
let status: 'pending' | 'processing' | 'succeeded' | 'failed' = 'pending';
let loading = false;
async function simulate() {
loading = true;
status = 'processing';
await new Promise((r) => setTimeout(r, 1200));
status = Math.random() > 0.5 ? 'succeeded' : 'failed';
loading = false;
}
</script>
<div class="space-y-3">
<button
class={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition ${statusTone(status)}`}
aria-busy={loading}
aria-disabled={loading}
disabled={loading}
on:click={simulate}
>
{#if loading}
<span class="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{/if}
{status === 'pending'
? 'Start'
: status === 'processing'
? 'Processing...'
: status === 'succeeded'
? 'Done'
: 'Retry'}
</button>
<p class="text-sm text-slate-600">Current status: {status}</p>
</div>
Run and verify:
npm run dev
# Page shows the button; click it to see Processing... then success or failure color
Common questions and notes
- Inconsistent status values: backend may return
succeeded/completed; handle both. - Long class strings: you can use
clsxorclassnames, but keep mapping logic centralized. - Accessibility gaps: custom elements need
role/tabindex/aria-label; loading needsaria-busy. - Disabled styles: add
opacity-60 cursor-not-allowedfor clarity. - Default text: use
??instead of||to avoid empty string issues.
Testing checklist
- Unit:
statusTonereturns expected classes for each state. - Component: when loading,
button.disabled === trueandaria-busy="true"exists. - Accessibility: Tab focuses, Enter/Space triggers;
aria-labelpresent for icon buttons. - Visual: contrast ratio >= 4.5:1 for text on backgrounds.
Best practices
- Split mapping, structure, and a11y: function (state->class) + template + accessibility helpers.
- Define the state machine before styling; avoid scattered magic strings.
- Default to accessibility: keyboard, screen reader, and synchronized disabled/busy states.
- Provide a runnable example for team reuse.
Summary / Next steps
- The key is “state mapping + safe values + a11y sync”.
statusTonecentralizes styles,?.and??make data safe, ARIA makes it production ready.- Next: align with your design system (colors/sizes/icons), publish a
Buttoncomponent, and add Playwright a11y checks.
References
- Svelte docs: events and accessibility
- MDN: Optional chaining, Nullish coalescing
- WAI-ARIA Authoring Practices: Button
Call to Action (CTA)
- Copy the example into your component library and replace colors/states.
- Audit existing buttons for missing
aria-*and disabled styles.