Theme Toggle
Theme toggle button that repaints the whole page through the View Transition API — a rectangle or circle clip-path reveal, or slats that open across the screen like a shutter.
Preview
Rectangle
Circle
Circle blur
Blinds
TSXcomponents/previews/motion/theme-toggle.preview.tsx
"use client";
import { ThemeToggle, type ThemeVariant } from "@/components/motion/theme-toggle";
const VARIANTS: { variant: ThemeVariant; label: string }[] = [
{ variant: "rectangle", label: "Rectangle" },
{ variant: "circle", label: "Circle" },
{ variant: "circle-blur", label: "Circle blur" },
{ variant: "blinds", label: "Blinds" },
];
export function ThemeTogglePreview() {
return (
<div className="flex h-full w-full items-center justify-center gap-5">
{VARIANTS.map(({ variant, label }) => (
<div key={variant} className="flex flex-col items-center gap-2">
<ThemeToggle
variant={variant}
start="bottom-up"
className="rounded-xl border border-border bg-background p-2.5"
iconClassName="h-5 w-5"
/>
<span className="text-[11px] text-muted-foreground">{label}</span>
</div>
))}
</div>
);
}
TSXcomponents/motion/theme-toggle.tsx
"use client";
// beui.dev/components/motion/theme-toggle
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useReducedMotion } from "motion/react";
import { useEffect, useState, type ComponentPropsWithoutRef } from "react";
import { ActionSwapIcon } from "@/components/motion/action-swap";
import { EASE_OUT_CSS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ThemeVariant = "rectangle" | "circle" | "circle-blur" | "blinds";
export type RectStart =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
| "center"
| "bottom-up";
export interface ThemeToggleProps
extends Omit<ComponentPropsWithoutRef<"button">, "children" | "onClick"> {
/** Animation variant. Default: "rectangle". */
variant?: ThemeVariant;
/** Origin direction for the reveal. Default: "bottom-up". */
start?: RectStart;
iconClassName?: string;
}
const VT_STYLE_ID = "beui-theme-toggle-vt";
// View transitions animate in CSS, not motion springs, so easing here is
// either EASE_OUT_CSS or a keyword. The circle variants keep the Material
// standard curve because their reveal expands symmetrically rather than
// decelerating. Durations differ per variant to match native OS mode switches.
const VT_CSS = `
html[data-beui-vt="rect"]::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
html[data-beui-vt="rect"]::view-transition-new(root) {
mix-blend-mode: normal;
animation: beui-rect-reveal 400ms ease-out;
}
html[data-beui-vt="circle"]::view-transition-old(root),
html[data-beui-vt="circle-blur"]::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
html[data-beui-vt="circle"]::view-transition-new(root) {
mix-blend-mode: normal;
animation: beui-circle-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1);
}
html[data-beui-vt="circle-blur"]::view-transition-new(root) {
mix-blend-mode: normal;
animation: beui-circle-blur-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1);
}
html[data-beui-vt="blinds"]::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
/* Slats: a masked band widens inside every 72px tile, so the new theme opens
across the page like a shutter. The band edge has to be a registered custom
property — mask-image itself is not animatable, but it re-resolves every
frame the property ticks. mask-size fixes the tile at 72px rather than
letting a repeating gradient's last stop define it, which is what keeps the
20px soft edge from dragging the tile wider than the slat and leaving a
feathered gap that never closes; it also means both ends land clean, fully
transparent at -20px and fully opaque at 72px. Falling back to no mask
(unregistered property, so the var is invalid) reveals the page in one
step. */
@property --beui-vt-slat {
syntax: "<length>";
inherits: false;
initial-value: 72px;
}
html[data-beui-vt="blinds"]::view-transition-new(root) {
mix-blend-mode: normal;
mask-image: linear-gradient(
90deg,
#000 0 var(--beui-vt-slat),
transparent calc(var(--beui-vt-slat) + 20px)
);
mask-size: 72px 100%;
mask-repeat: repeat;
animation: beui-blinds-reveal 700ms ${EASE_OUT_CSS};
}
@keyframes beui-rect-reveal {
from { clip-path: var(--beui-vt-from, inset(100% 0 0 0)); }
to { clip-path: inset(0 0 0 0); }
}
@keyframes beui-circle-reveal {
from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); }
to { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); }
}
@keyframes beui-circle-blur-reveal {
from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); filter: blur(8px); }
to { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); filter: blur(0px); }
}
@keyframes beui-blinds-reveal {
from { --beui-vt-slat: -20px; }
to { --beui-vt-slat: 72px; }
}
`;
const RECT_FROM: Record<RectStart, string> = {
"top-left": "inset(0 100% 100% 0)",
"top-right": "inset(0 0 100% 100%)",
"bottom-left": "inset(100% 100% 0 0)",
"bottom-right":"inset(100% 0 0 100%)",
center: "inset(50% 50% 50% 50%)",
"bottom-up": "inset(100% 0 0 0)",
};
const CIRCLE_ORIGIN: Record<RectStart, string> = {
"top-left": "0% 0%",
"top-right": "100% 0%",
"bottom-left": "0% 100%",
"bottom-right":"100% 100%",
center: "50% 50%",
"bottom-up": "50% 100%",
};
export function useThemeToggle({
variant = "rectangle",
start = "bottom-up",
}: { variant?: ThemeVariant; start?: RectStart } = {}) {
const { setTheme, resolvedTheme } = useTheme();
const reduce = useReducedMotion() ?? false;
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (document.getElementById(VT_STYLE_ID)) return;
const el = document.createElement("style");
el.id = VT_STYLE_ID;
el.textContent = VT_CSS;
document.head.appendChild(el);
}, []);
const isDark = mounted && resolvedTheme === "dark";
const toggle = () => {
const next = isDark ? "light" : "dark";
if (reduce || !("startViewTransition" in document)) {
setTheme(next);
return;
}
const root = document.documentElement;
if (variant === "rectangle") {
root.style.setProperty("--beui-vt-from", RECT_FROM[start]);
root.dataset.beuiVt = "rect";
} else if (variant === "blinds") {
// Slats sweep the whole viewport; there is no origin point to set.
root.dataset.beuiVt = "blinds";
} else {
root.style.setProperty("--beui-vt-origin", CIRCLE_ORIGIN[start]);
root.dataset.beuiVt = variant;
}
const vt = (
document as Document & {
startViewTransition(cb: () => void): { finished: Promise<void> };
}
).startViewTransition(() => setTheme(next));
vt.finished.finally(() => {
delete root.dataset.beuiVt;
});
};
return { isDark, mounted, toggle };
}
export function ThemeToggle({
variant = "rectangle",
start = "bottom-up",
className,
iconClassName,
...rest
}: ThemeToggleProps) {
const { isDark, mounted, toggle } = useThemeToggle({ variant, start });
return (
<button
type="button"
aria-label={mounted && isDark ? "Switch to light mode" : "Switch to dark mode"}
onClick={toggle}
className={cn("flex items-center justify-center", className)}
{...rest}
>
{mounted ? (
<ActionSwapIcon
value={isDark ? "dark" : "light"}
animation="blur"
className={iconClassName}
>
{isDark ? (
<Sun className={iconClassName} />
) : (
<Moon className={iconClassName} />
)}
</ActionSwapIcon>
) : (
<span className={iconClassName} aria-hidden="true" />
)}
</button>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/theme-toggle
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion next-themes tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/theme-toggle.tsx
"use client";
// beui.dev/components/motion/theme-toggle
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useReducedMotion } from "motion/react";
import { useEffect, useState, type ComponentPropsWithoutRef } from "react";
import { ActionSwapIcon } from "@/components/motion/action-swap";
import { EASE_OUT_CSS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ThemeVariant = "rectangle" | "circle" | "circle-blur" | "blinds";
export type RectStart =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
| "center"
| "bottom-up";
export interface ThemeToggleProps
extends Omit<ComponentPropsWithoutRef<"button">, "children" | "onClick"> {
/** Animation variant. Default: "rectangle". */
variant?: ThemeVariant;
/** Origin direction for the reveal. Default: "bottom-up". */
start?: RectStart;
iconClassName?: string;
}
const VT_STYLE_ID = "beui-theme-toggle-vt";
// View transitions animate in CSS, not motion springs, so easing here is
// either EASE_OUT_CSS or a keyword. The circle variants keep the Material
// standard curve because their reveal expands symmetrically rather than
// decelerating. Durations differ per variant to match native OS mode switches.
const VT_CSS = `
html[data-beui-vt="rect"]::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
html[data-beui-vt="rect"]::view-transition-new(root) {
mix-blend-mode: normal;
animation: beui-rect-reveal 400ms ease-out;
}
html[data-beui-vt="circle"]::view-transition-old(root),
html[data-beui-vt="circle-blur"]::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
html[data-beui-vt="circle"]::view-transition-new(root) {
mix-blend-mode: normal;
animation: beui-circle-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1);
}
html[data-beui-vt="circle-blur"]::view-transition-new(root) {
mix-blend-mode: normal;
animation: beui-circle-blur-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1);
}
html[data-beui-vt="blinds"]::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
/* Slats: a masked band widens inside every 72px tile, so the new theme opens
across the page like a shutter. The band edge has to be a registered custom
property — mask-image itself is not animatable, but it re-resolves every
frame the property ticks. mask-size fixes the tile at 72px rather than
letting a repeating gradient's last stop define it, which is what keeps the
20px soft edge from dragging the tile wider than the slat and leaving a
feathered gap that never closes; it also means both ends land clean, fully
transparent at -20px and fully opaque at 72px. Falling back to no mask
(unregistered property, so the var is invalid) reveals the page in one
step. */
@property --beui-vt-slat {
syntax: "<length>";
inherits: false;
initial-value: 72px;
}
html[data-beui-vt="blinds"]::view-transition-new(root) {
mix-blend-mode: normal;
mask-image: linear-gradient(
90deg,
#000 0 var(--beui-vt-slat),
transparent calc(var(--beui-vt-slat) + 20px)
);
mask-size: 72px 100%;
mask-repeat: repeat;
animation: beui-blinds-reveal 700ms ${EASE_OUT_CSS};
}
@keyframes beui-rect-reveal {
from { clip-path: var(--beui-vt-from, inset(100% 0 0 0)); }
to { clip-path: inset(0 0 0 0); }
}
@keyframes beui-circle-reveal {
from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); }
to { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); }
}
@keyframes beui-circle-blur-reveal {
from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); filter: blur(8px); }
to { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); filter: blur(0px); }
}
@keyframes beui-blinds-reveal {
from { --beui-vt-slat: -20px; }
to { --beui-vt-slat: 72px; }
}
`;
const RECT_FROM: Record<RectStart, string> = {
"top-left": "inset(0 100% 100% 0)",
"top-right": "inset(0 0 100% 100%)",
"bottom-left": "inset(100% 100% 0 0)",
"bottom-right":"inset(100% 0 0 100%)",
center: "inset(50% 50% 50% 50%)",
"bottom-up": "inset(100% 0 0 0)",
};
const CIRCLE_ORIGIN: Record<RectStart, string> = {
"top-left": "0% 0%",
"top-right": "100% 0%",
"bottom-left": "0% 100%",
"bottom-right":"100% 100%",
center: "50% 50%",
"bottom-up": "50% 100%",
};
export function useThemeToggle({
variant = "rectangle",
start = "bottom-up",
}: { variant?: ThemeVariant; start?: RectStart } = {}) {
const { setTheme, resolvedTheme } = useTheme();
const reduce = useReducedMotion() ?? false;
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (document.getElementById(VT_STYLE_ID)) return;
const el = document.createElement("style");
el.id = VT_STYLE_ID;
el.textContent = VT_CSS;
document.head.appendChild(el);
}, []);
const isDark = mounted && resolvedTheme === "dark";
const toggle = () => {
const next = isDark ? "light" : "dark";
if (reduce || !("startViewTransition" in document)) {
setTheme(next);
return;
}
const root = document.documentElement;
if (variant === "rectangle") {
root.style.setProperty("--beui-vt-from", RECT_FROM[start]);
root.dataset.beuiVt = "rect";
} else if (variant === "blinds") {
// Slats sweep the whole viewport; there is no origin point to set.
root.dataset.beuiVt = "blinds";
} else {
root.style.setProperty("--beui-vt-origin", CIRCLE_ORIGIN[start]);
root.dataset.beuiVt = variant;
}
const vt = (
document as Document & {
startViewTransition(cb: () => void): { finished: Promise<void> };
}
).startViewTransition(() => setTheme(next));
vt.finished.finally(() => {
delete root.dataset.beuiVt;
});
};
return { isDark, mounted, toggle };
}
export function ThemeToggle({
variant = "rectangle",
start = "bottom-up",
className,
iconClassName,
...rest
}: ThemeToggleProps) {
const { isDark, mounted, toggle } = useThemeToggle({ variant, start });
return (
<button
type="button"
aria-label={mounted && isDark ? "Switch to light mode" : "Switch to dark mode"}
onClick={toggle}
className={cn("flex items-center justify-center", className)}
{...rest}
>
{mounted ? (
<ActionSwapIcon
value={isDark ? "dark" : "light"}
animation="blur"
className={iconClassName}
>
{isDark ? (
<Sun className={iconClassName} />
) : (
<Moon className={iconClassName} />
)}
</ActionSwapIcon>
) : (
<span className={iconClassName} aria-hidden="true" />
)}
</button>
);
}
TSXcomponents/motion/action-swap.tsx
"use client";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ActionSwapItem = {
id: string;
label: ReactNode;
icon?: ReactNode;
ariaLabel?: string;
};
export type ActionSwapButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type ActionSwapButtonSize = "sm" | "md" | "lg" | "icon";
export type ActionSwapAnimation = "blur" | "roll" | "cascade";
/** Animations with a single-element variant set (cascade animates per letter). */
type CoreAnimation = "blur" | "roll";
export interface ActionSwapButtonProps extends Omit<
HTMLMotionProps<"button">,
"children" | "onChange"
> {
items: ActionSwapItem[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string, item: ActionSwapItem) => void;
variant?: ActionSwapButtonVariant;
size?: ActionSwapButtonSize;
animation?: ActionSwapAnimation;
iconOnly?: boolean;
cycle?: boolean;
}
export interface ActionSwapTextProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
export interface ActionSwapIconProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
const BLUR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const;
const ROLL_TRANSITION = SPRING_SWAP;
const ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;
const SWAP_BLUR = "blur(8px)";
const ROLL_BLUR = "blur(3px)";
// Cascade rolls the label one letter at a time, left to right. The leaving
// and landing strings overlap as independent layers (no shared cells), so
// proportional glyph widths never jitter. Exits cascade at half the enter
// stagger so the tail of the old label lingers briefly.
const CASCADE_STAGGER = 0.025;
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const TEXT_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.94,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: "90%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: "-90%",
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const ICON_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.25,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: 12, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: -12,
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
outline: "border border-border bg-transparent text-foreground hover:bg-primary/5",
ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground",
};
const SIZE_CLASS: Record<ActionSwapButtonSize, string> = {
sm: "h-8 gap-1.5 rounded-full px-3 text-xs",
md: "h-10 gap-2 rounded-full px-4 text-sm",
lg: "h-12 gap-2.5 rounded-full px-5 text-base",
icon: "h-10 w-10 rounded-full",
};
export function ActionSwapText({
value,
children,
animation = "blur",
className,
}: ActionSwapTextProps) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));
});
// Cascade needs a plain string to split into letters; non-string content
// and reduced motion fall back to the closest single-element animation.
const label = typeof children === "string" ? children : null;
const cascade = animation === "cascade" && label !== null && !reduce;
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span
className={cn("relative inline-block overflow-hidden whitespace-nowrap align-bottom", className)}
style={{
width,
transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,
}}
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
{/* Letters are decorative fragments; readers get the whole label. */}
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, i) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.
key={i}
custom={i * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`${animation}-${value}`}
variants={TEXT_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</span>
);
}
export function ActionSwapIcon({
value,
children,
animation = "blur",
className,
}: ActionSwapIconProps) {
const reduce = useReducedMotion();
// Icons are single elements — cascade maps to its closest motion, roll.
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span className={cn("relative inline-grid shrink-0 place-items-center overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${animation}-${value}`}
aria-hidden
variants={ICON_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
</span>
);
}
export function ActionSwapButton({
items,
value,
defaultValue,
onValueChange,
variant = "secondary",
size = "md",
animation = "blur",
iconOnly = size === "icon",
cycle = true,
className,
disabled,
onClick,
...rest
}: ActionSwapButtonProps) {
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);
const currentValue = value ?? internalValue;
const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));
const activeItem = items[activeIndex] ?? items[0];
const hasIcon = items.some((item) => item.icon);
const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;
if (!activeItem) return null;
const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === "string" ? activeItem.label : undefined);
return (
<motion.button
type="button"
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center overflow-hidden font-medium transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
aria-label={accessibleLabel}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || disabled || !cycle || !nextItem) return;
if (value === undefined) setInternalValue(nextItem.id);
onValueChange?.(nextItem.id, nextItem);
}}
{...rest}
>
{hasIcon ? (
<ActionSwapIcon value={activeItem.id} animation={animation} className="h-4 w-4">
{activeItem.icon ?? null}
</ActionSwapIcon>
) : null}
{!iconOnly ? (
<ActionSwapText value={activeItem.id} animation={animation}>
{activeItem.label}
</ActionSwapText>
) : null}
</motion.button>
);
}
API Reference
useThemeToggle
variant?"rectangle" | "circle" | "circle-blur" | "blinds"rectanglestart?"top-left" | "top-right" | "bottom-left" | "bottom-right" | "center" | "bottom-up"bottom-upThemeToggle
variant?"rectangle" | "circle" | "circle-blur" | "blinds"Animation variant. Default: "rectangle".
rectanglestart?"top-left" | "top-right" | "bottom-left" | "bottom-right" | "center" | "bottom-up"Origin direction for the reveal. Default: "bottom-up".
bottom-upiconClassName?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated