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 undefined or 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, tabindex help 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, and disabled stay 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:keydown for Enter or Space.
  • Sync loading/disabled state with aria-busy and aria-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-label for screen readers.

6) Skeleton loading / disabled strategy

  • Loading: show spinner, block double-submit; use disabled and aria-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 clsx or classnames, but keep mapping logic centralized.
  • Accessibility gaps: custom elements need role/tabindex/aria-label; loading needs aria-busy.
  • Disabled styles: add opacity-60 cursor-not-allowed for clarity.
  • Default text: use ?? instead of || to avoid empty string issues.

Testing checklist

  • Unit: statusTone returns expected classes for each state.
  • Component: when loading, button.disabled === true and aria-busy="true" exists.
  • Accessibility: Tab focuses, Enter/Space triggers; aria-label present 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”.
  • statusTone centralizes styles, ?. and ?? make data safe, ARIA makes it production ready.
  • Next: align with your design system (colors/sizes/icons), publish a Button component, 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.