Toggle Button
A two-state button that can be either "pressed" or "not pressed".
Demo
Accessibility Features
WAI-ARIA Roles
-
button- Indicates a widget that triggers an action when activated
WAI-ARIA button role (opens in new tab)
WAI-ARIA States
aria-pressed
Indicates the current pressed state of the toggle button.
| Values | true | false (tri-state buttons may also use "mixed") |
| Required | Yes (for toggle buttons) |
| Default | initialPressed prop (default: false) |
| Change Trigger | Click, Enter, Space |
| Reference | aria-pressed (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Space | Toggle the button state |
| Enter | Toggle the button state |
Source Code
ToggleButton.tsx
import { cn } from "@/lib/utils";
import { useCallback, useState } from "react";
export interface ToggleButtonProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onClick" | "type" | "aria-pressed" | "onToggle"
> {
/** Initial pressed state */
initialPressed?: boolean;
/** Button label text */
children: React.ReactNode;
/** Callback fired when toggle state changes */
onPressedChange?: (pressed: boolean) => void;
/** Custom indicator for pressed state (default: "â") */
pressedIndicator?: React.ReactNode;
/** Custom indicator for unpressed state (default: "â") */
unpressedIndicator?: React.ReactNode;
}
export const ToggleButton: React.FC<ToggleButtonProps> = ({
initialPressed = false,
children,
onPressedChange,
pressedIndicator = "â",
unpressedIndicator = "â",
className = "",
...buttonProps
}) => {
const [pressed, setPressed] = useState(initialPressed);
const handleClick = useCallback(() => {
setPressed(!pressed);
onPressedChange?.(!pressed);
}, [pressed, onPressedChange]);
return (
<button
type="button"
{...buttonProps}
className={cn("apg-toggle-button", className)}
aria-pressed={pressed}
onClick={handleClick}
>
<span className="apg-toggle-button-content">{children}</span>
<span className="apg-toggle-indicator" aria-hidden="true">
{pressed ? pressedIndicator : unpressedIndicator}
</span>
</button>
);
};
export default ToggleButton; Usage
Example
import { ToggleButton } from './ToggleButton';
import { Volume2, VolumeOff } from 'lucide-react';
function App() {
return (
<ToggleButton
initialPressed={false}
onPressedChange={(pressed) => console.log('Muted:', pressed)}
pressedIndicator={<VolumeOff size={20} />}
unpressedIndicator={<Volume2 size={20} />}
>
Mute
</ToggleButton>
);
} API
| Prop | Type | Default | Description |
|---|---|---|---|
initialPressed | boolean | false | Initial pressed state |
onPressedChange | (pressed: boolean) => void | - | Callback when state changes |
pressedIndicator | ReactNode | "â" | Custom indicator for pressed state |
unpressedIndicator | ReactNode | "â" | Custom indicator for unpressed state |
children | ReactNode | - | Button label |
All other props are passed to the underlying <button> element.
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.
Test Categories
High Priority: APG Keyboard Interaction
| Test | Description |
|---|---|
Space key toggles | Pressing Space toggles the button state |
Enter key toggles | Pressing Enter toggles the button state |
Tab navigation | Tab key moves focus between buttons |
Disabled Tab skip | Disabled buttons are skipped in Tab order |
High Priority: APG ARIA Attributes
| Test | Description |
|---|---|
role="button" | Has implicit button role (via <button>) |
aria-pressed initial | Initial state is aria-pressed="false" |
aria-pressed toggle | Click changes aria-pressed to true |
type="button" | Explicit button type prevents form submission |
disabled state | Disabled buttons don't change state on click |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
accessible name | Button has an accessible name from content |
Low Priority: HTML Attribute Inheritance
| Test | Description |
|---|---|
className merge | Custom classes are merged with component classes |
data-* attributes | Custom data attributes are passed through |
Testing Tools
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- jest-axe (opens in new tab) - Automated accessibility testing
See testing-strategy.md (opens in new tab) for full documentation.
ToggleButton.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { describe, expect, it, vi } from "vitest";
import { ToggleButton } from "./ToggleButton";
describe("ToggleButton", () => {
// đ´ High Priority: APG ćşć ăŽć ¸ĺż
describe("APG: ăăźăăźăćä˝", () => {
it("Space ăăźă§ăă°ăŤăă", async () => {
const user = userEvent.setup();
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-pressed", "false");
button.focus();
await user.keyboard(" ");
expect(button).toHaveAttribute("aria-pressed", "true");
});
it("Enter ăăźă§ăă°ăŤăă", async () => {
const user = userEvent.setup();
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-pressed", "false");
button.focus();
await user.keyboard("{Enter}");
expect(button).toHaveAttribute("aria-pressed", "true");
});
it("Tab ăăźă§ăăŠăźăŤăšç§ťĺĺŻč˝", async () => {
const user = userEvent.setup();
render(
<>
<ToggleButton>Button 1</ToggleButton>
<ToggleButton>Button 2</ToggleButton>
</>
);
await user.tab();
expect(screen.getByRole("button", { name: "Button 1" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "Button 2" })).toHaveFocus();
});
it("disabled ć㯠Tab ăăźăšăăă", async () => {
const user = userEvent.setup();
render(
<>
<ToggleButton>Button 1</ToggleButton>
<ToggleButton disabled>Button 2</ToggleButton>
<ToggleButton>Button 3</ToggleButton>
</>
);
await user.tab();
expect(screen.getByRole("button", { name: "Button 1" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "Button 3" })).toHaveFocus();
});
});
describe("APG: ARIA ĺąć§", () => {
it('role="button" ăćă¤ďźćéťçďź', () => {
render(<ToggleButton>Mute</ToggleButton>);
expect(screen.getByRole("button")).toBeInTheDocument();
});
it('ĺćçść
ă§ aria-pressed="false"', () => {
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-pressed", "false");
});
it('ăŻăŞăăŻĺžăŤ aria-pressed="true" ăŤĺ¤ăă', async () => {
const user = userEvent.setup();
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-pressed", "false");
await user.click(button);
expect(button).toHaveAttribute("aria-pressed", "true");
});
it('type="button" ăč¨ĺŽăăăŚăă', () => {
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("type", "button");
});
it("disabled çść
ă§ aria-pressed ĺ¤ć´ä¸ĺŻ", async () => {
const user = userEvent.setup();
render(<ToggleButton disabled>Mute</ToggleButton>);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-pressed", "false");
await user.click(button);
expect(button).toHaveAttribute("aria-pressed", "false");
});
});
// đĄ Medium Priority: ă˘ăŻăťăˇăăŞăăŁć¤č¨ź
describe("ă˘ăŻăťăˇăăŞăăŁ", () => {
it("axe ăŤăă WCAG 2.1 AA éĺăăŞă", async () => {
const { container } = render(<ToggleButton>Mute</ToggleButton>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("ă˘ăŻăťăˇăăŤăăźă ăč¨ĺŽăăăŚăă", () => {
render(<ToggleButton>Mute Audio</ToggleButton>);
expect(
screen.getByRole("button", { name: /Mute Audio/i })
).toBeInTheDocument();
});
});
describe("Props", () => {
it("initialPressed=true ă§ćźä¸çść
ă§ăŹăłăăŞăłă°ăăă", () => {
render(<ToggleButton initialPressed>Mute</ToggleButton>);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-pressed", "true");
});
it("onPressedChange ăçść
ĺ¤ĺćăŤĺźăłĺşăăă", async () => {
const handlePressedChange = vi.fn();
const user = userEvent.setup();
render(
<ToggleButton onPressedChange={handlePressedChange}>Mute</ToggleButton>
);
await user.click(screen.getByRole("button"));
expect(handlePressedChange).toHaveBeenCalledWith(true);
await user.click(screen.getByRole("button"));
expect(handlePressedChange).toHaveBeenCalledWith(false);
});
});
describe("ăŤăšăżă ă¤ăłă¸ăąăźăżăź", () => {
it("ăăăŠăŤăă§â/âă¤ăłă¸ăąăźăżăźă襨示ăăă", () => {
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole("button");
const indicator = button.querySelector(".apg-toggle-indicator");
expect(indicator).toHaveTextContent("â");
});
it("pressedIndicator ă§ăŤăšăżă ă¤ăłă¸ăąăźăżăźăč¨ĺŽă§ăă", () => {
render(
<ToggleButton initialPressed pressedIndicator="đ">
Mute
</ToggleButton>
);
const button = screen.getByRole("button");
const indicator = button.querySelector(".apg-toggle-indicator");
expect(indicator).toHaveTextContent("đ");
});
it("unpressedIndicator ă§ăŤăšăżă ă¤ăłă¸ăąăźăżăźăč¨ĺŽă§ăă", () => {
render(
<ToggleButton unpressedIndicator="đ">Mute</ToggleButton>
);
const button = screen.getByRole("button");
const indicator = button.querySelector(".apg-toggle-indicator");
expect(indicator).toHaveTextContent("đ");
});
it("ăă°ăŤćăŤăŤăšăżă ă¤ăłă¸ăąăźăżăźăĺăćżăă", async () => {
const user = userEvent.setup();
render(
<ToggleButton pressedIndicator="đ" unpressedIndicator="đ">
Mute
</ToggleButton>
);
const button = screen.getByRole("button");
const indicator = button.querySelector(".apg-toggle-indicator");
expect(indicator).toHaveTextContent("đ");
await user.click(button);
expect(indicator).toHaveTextContent("đ");
await user.click(button);
expect(indicator).toHaveTextContent("đ");
});
it("ReactNode ă¨ăăŚăŤăšăżă ă¤ăłă¸ăąăźăżăźă渥ăă", () => {
render(
<ToggleButton
initialPressed
pressedIndicator={<span data-testid="custom-icon">X</span>}
>
Mute
</ToggleButton>
);
expect(screen.getByTestId("custom-icon")).toBeInTheDocument();
});
it("ăŤăšăżă ă¤ăłă¸ăąăźăżăźă§ă aria-hidden ăçśćăăă", () => {
render(
<ToggleButton pressedIndicator="đ" unpressedIndicator="đ">
Mute
</ToggleButton>
);
const button = screen.getByRole("button");
const indicator = button.querySelector(".apg-toggle-indicator");
expect(indicator).toHaveAttribute("aria-hidden", "true");
});
it("ăŤăšăżă ă¤ăłă¸ăąăźăżăźă§ă axe éĺăăŞă", async () => {
const { container } = render(
<ToggleButton pressedIndicator="đ" unpressedIndicator="đ">
Mute
</ToggleButton>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// đ˘ Low Priority: ćĄĺźľć§
describe("HTML ĺąć§çśćż", () => {
it("className ăćŁăăăăźă¸ăăă", () => {
render(<ToggleButton className="custom-class">Mute</ToggleButton>);
const button = screen.getByRole("button");
expect(button).toHaveClass("custom-class");
expect(button).toHaveClass("apg-toggle-button");
});
it("data-* ĺąć§ăçśćżăăă", () => {
render(<ToggleButton data-testid="custom-toggle">Mute</ToggleButton>);
expect(screen.getByTestId("custom-toggle")).toBeInTheDocument();
});
it("ĺčŚç´ ă React ăăźăă§ăćŁĺ¸¸ĺä˝", () => {
render(
<ToggleButton>
<span>Icon</span> Text
</ToggleButton>
);
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Icon");
expect(button).toHaveTextContent("Text");
});
});
});