Icon Toggle
Buttons

Icon Toggle

An icon button that morphs between two states with the house icon-swap crossfade, shipping star, bookmark, and pin presets plus an optimistic digit-roll count.

Install

npx shadcn@latest add @paragon/icon-toggle

Also installs: digit-roll

icon-toggle.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Bookmark, Pin, Star } from "lucide-react";
import { cn } from "@/lib/utils";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";

export type IconTogglePreset = "star" | "bookmark" | "pin";

const presets: Record<
  IconTogglePreset,
  {
    off: React.ReactNode;
    on: React.ReactNode;
    /** Color of the active state — semantic tokens only. */
    onClassName: string;
    labels: [off: string, on: string];
  }
> = {
  star: {
    off: <Star className="size-4" aria-hidden />,
    on: <Star className="size-4 fill-current" aria-hidden />,
    onClassName: "text-warning",
    labels: ["Star", "Starred"],
  },
  bookmark: {
    off: <Bookmark className="size-4" aria-hidden />,
    on: <Bookmark className="size-4 fill-current" aria-hidden />,
    onClassName: "text-foreground",
    labels: ["Bookmark", "Bookmarked"],
  },
  pin: {
    off: <Pin className="size-4 rotate-45" aria-hidden />,
    on: <Pin className="size-4 rotate-0 fill-current" aria-hidden />,
    onClassName: "text-foreground",
    labels: ["Pin", "Pinned"],
  },
};

export interface IconToggleProps
  extends Omit<React.ComponentProps<"button">, "children"> {
  /** Built-in icon pair; overridden by `icon`/`activeIcon`. */
  preset?: IconTogglePreset;
  /** Icon for the off state. */
  icon?: React.ReactNode;
  /** Icon for the on state. Falls back to `icon` if omitted. */
  activeIcon?: React.ReactNode;
  /** Classes applied while on — e.g. an active color token. */
  activeClassName?: string;
  /** Controlled pressed state. Leave undefined for uncontrolled. */
  pressed?: boolean;
  /** Initial state when uncontrolled. */
  defaultPressed?: boolean;
  /** Fires with the next state on every toggle. */
  onPressedChange?: (pressed: boolean) => void;
  /** Count excluding the current user; +1 is applied optimistically. */
  count?: number;
  /** Disables the icon-swap motion; state still changes. */
  static?: boolean;
}

/**
 * An icon button that morphs between two states with the house icon swap
 * (popLayout crossfade — scale 0.25 with a 4px blur, zero-bounce spring).
 * Ships star / bookmark / pin presets with sensible pressed labels and an
 * optimistic digit-roll count. Controlled or uncontrolled.
 */
export function IconToggle({
  preset = "star",
  icon,
  activeIcon,
  activeClassName,
  pressed: controlledPressed,
  defaultPressed = false,
  onPressedChange,
  count,
  static: isStatic = false,
  className,
  onClick,
  "aria-label": ariaLabel,
  ...props
}: IconToggleProps) {
  const reducedMotion = useReducedMotion();
  const [uncontrolledPressed, setUncontrolledPressed] =
    React.useState(defaultPressed);
  const isControlled = controlledPressed !== undefined;
  const isPressed = isControlled ? controlledPressed : uncontrolledPressed;

  const config = presets[preset];
  const offIcon = icon ?? config.off;
  const onIcon = activeIcon ?? icon ?? config.on;
  const onColor = icon ? (activeClassName ?? "text-foreground") : activeClassName ?? config.onClassName;
  const hasCount = typeof count === "number";

  const swapTransition =
    isStatic || reducedMotion
      ? { duration: 0 }
      : ({ type: "spring", duration: 0.3, bounce: 0 } as const);

  return (
    <button
      type="button"
      data-slot="icon-toggle"
      aria-pressed={isPressed}
      aria-label={ariaLabel ?? config.labels[isPressed ? 1 : 0]}
      onClick={(event) => {
        onClick?.(event);
        if (event.defaultPrevented) return;
        const next = !isPressed;
        if (!isControlled) setUncontrolledPressed(next);
        onPressedChange?.(next);
      }}
      className={cn(
        "pressable relative inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-lg text-muted-foreground outline-none",
        "transition-[color,background-color,scale] duration-150 ease-out",
        "hover:bg-accent hover:text-foreground",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        "disabled:pointer-events-none disabled:opacity-50",
        "after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2",
        hasCount ? "px-2.5 text-[13px] font-medium" : "w-9",
        isPressed && onColor,
        isPressed && hasCount && "hover:text-current",
        className,
      )}
      {...props}
    >
      <span className="relative flex size-4 items-center justify-center">
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={isPressed ? "on" : "off"}
            className="flex"
            initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
            transition={swapTransition}
          >
            {isPressed ? onIcon : offIcon}
          </motion.span>
        </AnimatePresence>
      </span>
      {hasCount && (
        <DigitRoll
          value={count + (isPressed ? 1 : 0)}
          static={isStatic}
          className="min-w-[1ch]"
        />
      )}
    </button>
  );
}