Skip to content
ssntl/ui

Toggle

A two-state button whose icon cross-fades between states, with the standard press.

Installation

$ pnpm dlx shadcn@latest add @ssntl/toggle

Usage

tsx
import { Toggle } from "@/components/ui/toggle"

<Toggle aria-label="Save product" off={<Bookmark />} on={<BookmarkCheck />} />
<Toggle off={<Bell />} on={<BellRing />}>Follow updates</Toggle>

Rules

What this component expects from you, as it ships in components/ui/toggle.md next to the code. The full file adds when to use it, keyboard, the UI foundations it applies and every shared rule.

components/ui/toggle.md
## Rules for Toggle

- **Toggle and selection feedback.** The standard 0.96 press in 150ms, never a slow or deep squash.
- The icon cross-fades between its two states; `aria-pressed` carries the state for screen readers.
- An icon-only toggle needs an `aria-label` that names the action, not the state.
Open the full rules file (opens in a new tab)

Code

components/ui/toggle.tsx
"use client"

import * as React from "react"
import { Toggle as TogglePrimitive } from "radix-ui"

import { cn } from "@/lib/utils"
import { buttonVariants } from "@/registry/ssntl/ui/button"

// A two-state button (Radix Toggle, aria-pressed): save, follow, pin.
// Toggle and selection feedback: the same 0.96 press in 150ms as every
// button, and the icon cross-fades between its two states (icon-swap), so
// the change reads instantly and the state stays in the icon and aria-pressed.
function Toggle({
  className,
  children,
  on,
  off,
  pressed,
  defaultPressed = false,
  onPressedChange,
  ...props
}: Omit<React.ComponentProps<typeof TogglePrimitive.Root>, "children"> & {
  /** The icon while pressed. */
  on: React.ReactNode
  /** The icon while not pressed. */
  off: React.ReactNode
  /** A visible label; without one, pass aria-label. */
  children?: React.ReactNode
}) {
  const [inner, setInner] = React.useState(defaultPressed)
  const current = pressed ?? inner

  return (
    <TogglePrimitive.Root
      data-slot="toggle"
      pressed={current}
      onPressedChange={(next) => {
        setInner(next)
        onPressedChange?.(next)
      }}
      className={cn(
        buttonVariants({ variant: "ghost", size: children ? "md" : "icon" }),
        !children && "px-0",
        "data-[state=on]:text-foreground",
        className
      )}
      {...props}
    >
      <span
        className="icon-swap"
        data-swapped={current}
        data-icon={children ? "inline-start" : undefined}
      >
        {off}
        {on}
      </span>
      {children}
    </TogglePrimitive.Root>
  )
}

export { Toggle }