Chapter 01 · Live Playground
Accessible Button
13 button patterns — basic, variants, loading, icon-only, toggle, tooltip, destructive and more.
- 2.1.1 Keyboard
- 2.4.7 Focus Visible
- 2.5.5 Target Size
- 4.1.2 Name/Role/Value
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><button></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 & 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><a href></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’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 <button>) </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 (“Delete item 42” not just “Delete”). </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.

