Actions
Toggle
A single button that stays pressed or unpressed, for one on or off choice such as pin, mute or wrap lines.
- Built on
<button>aria-pressed- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 1.2 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { Toggle } from "@/components/equip/toggle";
export default function Demo() {
return (
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Toggle aria-label="Pin to sidebar" defaultPressed>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9.75 2.25 13.75 6.25M11.5 4l-3 3-3-.5L4 8l4 4 1.5-1.5L9 7.5l3-3M6 10l-3.5 3.5" />
</svg>
</Toggle>
<Toggle>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M8 2.5a3.5 3.5 0 0 0-3.5 3.5v2.5L3 11h10l-1.5-2.5V6A3.5 3.5 0 0 0 8 2.5ZM6.5 13a1.5 1.5 0 0 0 3 0" />
</svg>
Watch
</Toggle>
</div>
);
}Your versionchange its options in Look, This part
Examples
Sizes and states
An icon-only toggle needs an aria-label; the label says what it does and aria-pressed says whether it is on.
Code
import { Toggle } from "@/components/equip/toggle";
function Star() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m8 2.25 1.75 3.6 3.95.55-2.85 2.8.7 3.95L8 11.3l-3.55 1.85.7-3.95L2.3 6.4l3.95-.55L8 2.25Z" />
</svg>
);
}
export default function Example() {
return (
<div style={{ display: "grid", gap: 12, justifyItems: "start" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Toggle size="s" aria-label="Star repository">
<Star />
</Toggle>
<Toggle size="s" defaultPressed aria-label="Star repository">
<Star />
</Toggle>
<Toggle size="s" disabled aria-label="Star repository">
<Star />
</Toggle>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Toggle>Show archived</Toggle>
<Toggle defaultPressed>Show archived</Toggle>
<Toggle disabled>Show archived</Toggle>
</div>
</div>
);
}Controlled
Keep the label the same when the state changes: a screen reader already says pressed or not pressed.
12:04:11 GET /api/invoices 200 41ms 12:04:12 POST /api/invoices 201 88ms 12:04:15 GET /api/customers?page=2 200 63ms
Code
"use client";
import { useState } from "react";
import { Toggle } from "@/components/equip/toggle";
const lines = [
"12:04:11 GET /api/invoices 200 41ms",
"12:04:12 POST /api/invoices 201 88ms",
"12:04:15 GET /api/customers?page=2 200 63ms",
];
export default function Example() {
const [wrap, setWrap] = useState(false);
return (
<div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 320 }}>
<Toggle size="s" pressed={wrap} onPressedChange={setWrap} style={{ justifySelf: "end" }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2.5 4h11M2.5 8h9a2 2 0 0 1 0 4H9m0 0 1.5-1.5M9 12l1.5 1.5M2.5 12h4" />
</svg>
Wrap lines
</Toggle>
<pre
style={{
margin: 0,
padding: 8,
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-m)",
font: "var(--eq-text-xs) / 1.5 var(--eq-font-mono)",
fontVariationSettings: "var(--eq-font-mono-settings)",
whiteSpace: wrap ? "pre-wrap" : "pre",
overflowX: "auto",
}}
>
{lines.join("\n")}
</pre>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
pressed | boolean | none | Controlled pressed state. |
defaultPressed | boolean | false | Uncontrolled starting state. |
onPressedChange | (pressed: boolean) => void | none | Called with the new state after each press. |
size | "s" | "m" | "m" | Control height, matching Button at the same size. |
...rest | ComponentProps<"button"> | none | Every native button attribute and ref. type defaults to button. An icon-only toggle needs an aria-label. |
Anatomy
| root | .eq-toggle | A native button with aria-pressed; square when its only child is an icon. |
|---|
Keyboard
| EnterSpace | Toggles the pressed state. |
|---|
Notes
- The state lives in aria-pressed, so a screen reader says toggle button, pressed or not pressed; keep the label the same in both states.
- Pressed is the solid fill with solid ink, the same as a pressed Toggle group item, so the two sit together.
- For several related toggles in a row, use Toggle group, which adds a toolbar and arrow-key movement.
- A toggle is not a form control and does not submit; for a setting saved with a form, use a switch or a checkbox.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/toggle.tsx49 lines
"use client";
import { useState } from "react";
import type { ComponentProps, MouseEvent } from "react";
import "./toggle.css";
export type ToggleProps = ComponentProps<"button"> & {
/** Controlled pressed state. */
pressed?: boolean;
/** Uncontrolled starting state. */
defaultPressed?: boolean;
/** Called with the new state after a press. */
onPressedChange?: (pressed: boolean) => void;
size?: "s" | "m";
};
export function Toggle({
pressed,
defaultPressed = false,
onPressedChange,
size = "m",
className,
onClick,
children,
...rest
}: ToggleProps) {
const [inner, setInner] = useState(defaultPressed);
const on = pressed ?? inner;
function handleClick(event: MouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
if (pressed === undefined) setInner(!on);
onPressedChange?.(!on);
}
return (
<button
type="button"
{...rest}
className={["eq-toggle", className].filter(Boolean).join(" ")}
data-size={size}
aria-pressed={on}
onClick={handleClick}
>
{children}
</button>
);
}components/equip/toggle.css79 lines
@layer equip {
/* The same item as a toggle group's, standing alone: so it carries its own border. */
.eq-toggle {
box-sizing: border-box;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
gap: var(--eq-space-2);
min-width: var(--eq-h-m);
height: var(--eq-h-m);
margin: 0;
padding: 0 var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink-2);
font: var(--eq-weight-strong) var(--eq-text-m) / 1 var(--eq-font);
white-space: nowrap;
cursor: pointer;
user-select: none;
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-toggle[data-size="s"] {
gap: var(--eq-space-1);
min-width: var(--eq-h-s);
height: var(--eq-h-s);
padding-inline: var(--eq-space-2);
border-radius: var(--eq-radius-s);
font-size: var(--eq-text-s);
}
/* An icon-only toggle is square. */
.eq-toggle:has(> svg:only-child) {
padding: 0;
}
.eq-toggle > svg {
flex: none;
width: 16px;
height: 16px;
}
@media (hover: hover) {
.eq-toggle:hover:not(:disabled, [aria-pressed="true"]) {
background: color-mix(in srgb, var(--eq-raised), var(--eq-ink) 6%);
color: var(--eq-ink);
}
.eq-toggle[aria-pressed="true"]:hover:not(:disabled) {
background: color-mix(in srgb, var(--eq-solid) 88%, var(--eq-solid-ink));
}
}
.eq-toggle:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-toggle[aria-pressed="true"] {
border-color: var(--eq-solid);
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
.eq-toggle:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}