Chapter 01 · Live Playground

Accessible Button

13 button patterns — basic, variants, loading, icon-only, toggle, tooltip, destructive and more.

import { useState } from "react";
import { Trash2, Save, Loader2, Info } from "lucide-react";
import "./styles.css";

export default function App() {
  const [loading, setLoading] = useState(false);
  const [pressed, setPressed] = useState(false);
  const [showTip, setShowTip] = useState(false);

  async function handleAsyncSave() {
    setLoading(true);
    await new Promise((r) => setTimeout(r, 1500));
    setLoading(false);
    alert("Saved!");
  }

  function handleDelete() {
    if (window.confirm("Delete item 42? This cannot be undone.")) {
      alert("Deleted.");
    }
  }

  return (
    <main className="demo">
      <h1>Accessible Button — 13 patterns (React)</h1>
      <p className="lede">
        Each section is a self-contained pattern. Copy the one you need.
      </p>

      {/* ========== 1. Basic native button ========== */}
      <section>
        <h2>1. Basic native button</h2>
        <p className="hint">
          Just use <code>&lt;button&gt;</code>. HTML gives you Enter + Space, focus,
          and screen-reader announcement for free.
        </p>
        <button type="button" onClick={() => alert("Clicked!")}>
          Click me
        </button>
      </section>

      {/* ========== 2. Primary / secondary / ghost variants ========== */}
      <section>
        <h2>2. Primary, secondary &amp; ghost variants</h2>
        <p className="hint">
          Different visual styles — same accessibility. Each MUST keep its focus ring.
        </p>
        <button type="button" className="btn btn-primary">Primary</button>
        <button type="button" className="btn btn-secondary">Secondary</button>
        <button type="button" className="btn btn-ghost">Ghost</button>
      </section>

      {/* ========== 3. Native disabled button ========== */}
      <section>
        <h2>3. Disabled button (native)</h2>
        <p className="hint">
          Native <code>disabled</code> blocks clicks AND removes the button from Tab order.
          Downside: some screen readers skip the announcement. See Example 13 for an alternative.
        </p>
        <button type="button" className="btn btn-primary" disabled>
          Cannot click
        </button>
      </section>

      {/* ========== 4. Loading button ========== */}
      <section>
        <h2>4. Loading button</h2>
        <p className="hint">
          <code>aria-busy</code> announces the busy state. <code>disabled</code> prevents
          double-submission while in flight.
        </p>
        <button
          type="button"
          className="btn btn-primary"
          onClick={handleAsyncSave}
          disabled={loading}
          aria-busy={loading}
        >
          {loading && <Loader2 size={16} className="spin" aria-hidden="true" />}
          {loading ? "Saving…" : "Save"}
        </button>
      </section>

      {/* ========== 5. Icon-only button ========== */}
      <section>
        <h2>5. Icon-only button</h2>
        <p className="hint">
          No visible text → MUST have <code>aria-label</code>. The icon is decorative,
          so <code>aria-hidden="true"</code> on it.
        </p>
        <button type="button" className="btn btn-icon" aria-label="Delete item">
          <Trash2 size={18} aria-hidden="true" />
        </button>
      </section>

      {/* ========== 6. Button with text and icon ========== */}
      <section>
        <h2>6. Button with text and icon</h2>
        <p className="hint">
          The visible text IS the accessible name. Icon stays decorative with
          <code>aria-hidden</code>. Do NOT add <code>aria-label</code> — that would
          replace the visible text in SR.
        </p>
        <button type="button" className="btn btn-primary">
          <Save size={16} aria-hidden="true" />
          <span>Save draft</span>
        </button>
      </section>

      {/* ========== 7. Submit button inside a form ========== */}
      <section>
        <h2>7. Submit button</h2>
        <p className="hint">
          Explicit <code>type="submit"</code>. Default button type varies by framework —
          always be explicit.
        </p>
        <form onSubmit={(e) => { e.preventDefault(); alert("Form submitted"); }}>
          <label>
            Your email
            <input type="email" required />
          </label>
          <button type="submit" className="btn btn-primary">Subscribe</button>
        </form>
      </section>

      {/* ========== 8. Link styled as a button ========== */}
      <section>
        <h2>8. Link styled as a button</h2>
        <p className="hint">
          If it navigates to another page, use <code>&lt;a href&gt;</code> — style it
          like a button but keep link semantics. Enter activates (Space does not).
        </p>
        <a href="https://example.com" className="btn btn-primary link-btn">
          Go to settings
        </a>
      </section>

      {/* ========== 9. ANTI-PATTERN: div/span used as a button ========== */}
      <section className="anti">
        <h2>9. ❌ div / span used as a button (anti-pattern)</h2>
        <p className="hint">
          No role, no keyboard, no screen-reader announcement. Breaks every a11y rule.
        </p>
        <div className="btn btn-primary" onClick={() => alert("Won't work with keyboard")}>
          Fake button (DON&rsquo;T do this)
        </div>

        <p className="hint" style={{ marginTop: 16 }}>
          If you absolutely MUST use a div (3rd-party widget, legacy code), you need
          ALL of: role, tabIndex, and keyboard handlers. Prefer a real <code>button</code>.
        </p>
        <div
          role="button"
          tabIndex={0}
          className="btn btn-primary"
          onClick={() => alert("Works")}
          onKeyDown={(e) => {
            if (e.key === "Enter" || e.key === " ") {
              e.preventDefault();
              alert("Works");
            }
          }}
        >
          Patched div (still worse than &lt;button&gt;)
        </div>
      </section>

      {/* ========== 10. Toggle button ========== */}
      <section>
        <h2>10. Toggle button</h2>
        <p className="hint">
          <code>aria-pressed</code> communicates the on/off state to screen readers.
          Visual + text can change too, but SR relies on the attribute.
        </p>
        <button
          type="button"
          className={"btn btn-secondary " + (pressed ? "is-pressed" : "")}
          aria-pressed={pressed}
          onClick={() => setPressed((p) => !p)}
        >
          {pressed ? "Following" : "Follow"}
        </button>
      </section>

      {/* ========== 11. Button with tooltip ========== */}
      <section>
        <h2>11. Button with tooltip</h2>
        <p className="hint">
          Tooltip appears on hover AND keyboard focus. Linked via
          <code>aria-describedby</code> so SR reads it after the button name.
        </p>
        <span className="tooltip-wrap">
          <button
            type="button"
            className="btn btn-icon"
            aria-label="More info"
            aria-describedby="info-tip"
            onFocus={() => setShowTip(true)}
            onBlur={() => setShowTip(false)}
            onMouseEnter={() => setShowTip(true)}
            onMouseLeave={() => setShowTip(false)}
          >
            <Info size={18} aria-hidden="true" />
          </button>
          {showTip && (
            <span id="info-tip" role="tooltip" className="tooltip">
              Shows more details about this item
            </span>
          )}
        </span>
      </section>

      {/* ========== 12. Destructive action ========== */}
      <section>
        <h2>12. Destructive action</h2>
        <p className="hint">
          Red styling + confirm dialog + SPECIFIC accessible name
          (&ldquo;Delete item 42&rdquo; not just &ldquo;Delete&rdquo;).
        </p>
        <button
          type="button"
          className="btn btn-danger"
          onClick={handleDelete}
          aria-label="Delete item 42 permanently"
        >
          <Trash2 size={16} aria-hidden="true" />
          <span>Delete</span>
        </button>
      </section>

      {/* ========== 13. aria-disabled button ========== */}
      <section>
        <h2>13. <code>aria-disabled</code> button (focusable but blocked)</h2>
        <p className="hint">
          Looks disabled, but is STILL focusable (unlike native disabled).
          Useful when you want users to discover it and get a reason.
          You MUST block the click in JS yourself.
        </p>
        <button
          type="button"
          className="btn btn-primary ak-aria-disabled"
          aria-disabled="true"
          onClick={(e) => {
            e.preventDefault();
            alert("Fill all required fields first to enable Save.");
          }}
        >
          Save (fields missing)
        </button>
      </section>
    </main>
  );
}

Tip: drag the vertical dividers to resize panels. Edit the code on the left — the preview on the right updates in real time. Switch between React and Vanilla at the top. Your changes stay in your browser only.

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo