Toggle
A button that stays down, standing on a raised tile with its glyph filled.
The idea
Pressed, it sits on the same white tile a dock item or a tab stands on, and its glyph fills; up, it is ghost. Two signals that agree, neither of them a colour, because a tone step on text alone cannot carry a state. It is aria-pressed, so a screen reader hears the state rather than inferring it from a style.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Toggle } from "mihirmodi-ui";
// The glyph fills when pressed rather than swapping shape.
<Toggle
aria-label="Bookmark"
pressed={saved}
onPressedChange={setSaved}
icon={(on) => <BookmarkIcon filled={on} />}
/>Props
| Prop | Type | Notes |
|---|---|---|
| pressed / onPressedChangeboolean / (p) => void | boolean / (p) => void | Controlled. |
| icon(pressed) => ReactNode | (pressed) => ReactNode | Given the state, so the glyph can fill rather than swap shape. |
| sizeButtonSize | ButtonSize= "icon" | Any of Button's sizes; give a label for the text ones. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { buttonBase, buttonSizes, type ButtonSize } from "./Button.js";
export type ToggleProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> & {
pressed: boolean;
onPressedChange: (pressed: boolean) => void;
size?: ButtonSize;
/**
* The mark, given whether it is on. Fill means chosen everywhere in this
* package, so a toggle's glyph should fill when pressed rather than swap shape:
* `icon={(on) => <BoldGlyph filled={on} />}`.
*/
icon?: (pressed: boolean) => ReactNode;
children?: ReactNode;
};
/**
* A button that stays down. Pressed, it stands on the raised highlight surface
* — the same white tile a dock item or a tab sits on — and its glyph fills; up,
* it is ghost. That is two signals agreeing, and neither of them is a colour,
* because a tone step on text alone cannot carry a state.
*
* It is `aria-pressed`, so a screen reader says "toggle button, pressed"
* rather than leaving the reader to infer a state from a style.
*/
export function Toggle({ pressed, onPressedChange, size = "icon", icon, children, className = "", ...rest }: ToggleProps) {
return (
<button
type="button"
aria-pressed={pressed}
onClick={() => onPressedChange(!pressed)}
className={`${buttonBase} ${buttonSizes[size]} ${
pressed
? "bg-highlight text-gray-1000 shadow-[0_0_0_0.5px_var(--color-gray-alpha-400),0_2px_2px_rgba(0,0,0,0.04)]"
: "text-quiet hover:bg-gray-alpha-200 hover:text-gray-1000"
} ${className}`}
{...rest}
>
{icon?.(pressed)}
{children}
</button>
);
}
"use client";
import type { ReactNode } from "react";
/**
* ── Icon fill as a signal ───────────────────────────────────────────────────
*
* In a row of icons, a filled one means *chosen* — the page you are on, the
* setting that is live. Nothing else may fill. Once fill means two things it
* means neither, and a row of icons has no other way to say which one is live.
*
* The fill is layered *over* the outline rather than swapped for it. Swapping
* cannot be animated (two different shapes), pops at the exchange, and needs
* both variants drawn to the same optical weight or the icon jumps. Layering
* gives you a free 200ms crossfade and guarantees the silhouettes agree, because
* it is the same path painted twice.
*
* Three kinds of mark are deliberately outside this rule, because for them fill
* is either impossible or a lie. Brand marks are official solid silhouettes with
* no outline form that would not be an off-brand redrawing — they say the same
* thing with colour instead. Open strokes (chevrons, arrows, a check, menu bars)
* have no interior to paint. And a lone toggle whose glyph changes shape has
* nothing to be picked out from, so a fill there is just weight.
*/
/** Sized by the caller; `filled` when this is the current one, or the one being pointed at. */
export type Glyph = { className?: string; filled?: boolean };
/** Class for the solid layer: fades in when chosen. */
export const fillFade = (on: boolean) =>
`transition-opacity duration-200 ease-out motion-reduce:transition-none ${on ? "opacity-100" : "opacity-0"}`;
/**
* Class for an interior detail line — the inverse. A line inside the silhouette
* has to fade *out* as the fill arrives underneath it, or it paints the knocked-out
* gap straight back in.
*/
export const detailFade = (on: boolean) =>
`transition-opacity duration-200 ease-out motion-reduce:transition-none ${on ? "opacity-0" : "opacity-100"}`;
/** The filled layer's paint. The stroke stays on the outline underneath. */
export const solid = { fill: "currentColor", stroke: "none" } as const;
/**
* The filled layer for an icon whose silhouette *encloses* detail — a briefcase's
* divider, an aperture's blades, a document's rules. A plain fill swallows them.
*
* This paints the whole 24×24 box through a mask instead. Children draw the shape
* in white (fill *and* stroke, so the silhouette matches the outline exactly) and
* the interior lines in black, at the stroke width the outline used — so each gap
* lands precisely where its line was.
*
* `id` must be unique per instance. `useId()` is the way.
*/
export function Knockout({ id, filled, children }: { id: string; filled: boolean; children: ReactNode }) {
return (
<g className={fillFade(filled)}>
<mask id={id} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
{children}
</mask>
<rect x="0" y="0" width="24" height="24" {...solid} mask={`url(#${id})`} />
</g>
);
}
/** Shared attributes for a 24×24 stroked glyph, so every icon in a set matches. */
export const glyphProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: "1.6",
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
} as const;
/* ── The few marks the components themselves draw ─────────────────────────────
Deliberately not an icon set. You already have one; this package should not
make you carry a second. These three exist because a component here draws
them and would otherwise take an icon prop for a thing that never varies. */
/** The dock's drop-up trigger. */
export function ChevronUpIcon({ className = "h-4 w-4" }: { className?: string }) {
return (
<svg {...glyphProps} strokeWidth="1.8" className={className}>
<path d="M6 15l6-6 6 6" />
</svg>
);
}
/** The same trigger for a dock pinned to the top — a disclosure points at what it opens. */
export function ChevronDownIcon({ className = "h-4 w-4" }: { className?: string }) {
return (
<svg {...glyphProps} strokeWidth="1.8" className={className}>
<path d="M6 9l6 6 6-6" />
</svg>
);
}
/** The toast's default mark. */
export function CheckIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
return (
<svg {...glyphProps} strokeWidth="1.8" className={className}>
<path d="M4.5 12.8l4.7 4.7L19.5 7.2" />
</svg>
);
}
/**
* Two bars that rotate into a cross. Not an icon swap: the same two elements
* rotate, so the open and closed states are the same object at two angles, and
* the transition between them is free.
*/
export function MenuGlyph({ open }: { open: boolean }) {
const bar =
"absolute h-[1.5px] w-[17px] rounded-full bg-current transition-transform duration-200 ease-geist motion-reduce:transition-none";
return (
<span aria-hidden className="relative flex h-[18px] w-[18px] items-center justify-center">
<span className={`${bar} ${open ? "rotate-45" : "-translate-y-[4px]"}`} />
<span className={`${bar} ${open ? "-rotate-45" : "translate-y-[4px]"}`} />
</span>
);
}
/** Send — a paper plane, for a composer's submit button. */
export function SendIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
return (
<svg {...glyphProps} strokeWidth="1.8" className={className}>
<path d="M12 19V5M6 11l6-6 6 6" />
</svg>
);
}
/** A four-point spark: the mark for an assistant, or anything generated. */
export function SparkleIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
return (
<svg {...glyphProps} className={className}>
<path d="M12 3.5c0 4.7 3.8 8.5 8.5 8.5-4.7 0-8.5 3.8-8.5 8.5 0-4.7-3.8-8.5-8.5-8.5 4.7 0 8.5-3.8 8.5-8.5z" />
</svg>
);
}
/** Close — two strokes. */
export function CloseIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
return (
<svg {...glyphProps} strokeWidth="1.8" className={className}>
<path d="M6.5 6.5l11 11M17.5 6.5l-11 11" />
</svg>
);
}
/** A paperclip, for an attach button. */
export function PaperclipIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
return (
<svg {...glyphProps} className={className}>
<path d="M20 11.5l-7.8 7.8a5 5 0 0 1-7.1-7.1l8.3-8.3a3.3 3.3 0 0 1 4.7 4.7l-8.3 8.3a1.7 1.7 0 0 1-2.4-2.4l7.6-7.6" />
</svg>
);
}
/** A document, for an attachment chip. */
export function FileIcon({ className = "h-3 w-3" }: { className?: string }) {
return (
<svg {...glyphProps} className={className}>
<path d="M13.5 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9z" />
<path d="M13.5 3.5V9H19" />
</svg>
);
}