← All components

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

terminal1 line
npm i mihirmodi-ui motion

The 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

Toggle — usage9 lines
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

PropNotes
pressed / onPressedChangeboolean / (p) => voidControlled.
icon(pressed) => ReactNodeGiven the state, so the glyph can fill rather than swap shape.
sizeButtonSizeAny 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.

packages/ui/src/components/actions/Toggle.tsx45 lines
"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>
  );
}
packages/ui/src/lib/glyph.tsx169 lines
"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>
  );
}