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.
npx shadcn@latest add @paragon/icon-toggleAlso installs: digit-roll
"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>
);
}