Components
import { MoonIcon, SunIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
import { Swap, SwapOff, SwapOn } from "@/components/ui/swap";
export function SwapDemo() {
return (
<div className="flex flex-col items-center gap-8">
<div className="flex flex-col items-center gap-2">
<span className="text-muted-foreground text-sm">Click to swap</span>
<Swap className="size-12 rounded-lg border bg-muted/50 hover:bg-muted">
<SwapOn>
<SunIcon className="size-6" />
</SwapOn>
<SwapOff>
<MoonIcon className="size-6" />
</SwapOff>
</Swap>
</div>
<div className="flex flex-col items-center gap-2">
<span className="text-muted-foreground text-sm">Hover to swap</span>
<Swap
activationMode="hover"
className="size-12 rounded-lg border bg-muted/50"
>
<SwapOn>
<Volume2Icon className="size-6" />
</SwapOn>
<SwapOff>
<VolumeXIcon className="size-6" />
</SwapOff>
</Swap>
</div>
</div>
);
}Installation
CLI
npx shadcn@latest add @diceui/swapManual
Install the following dependencies:
npm install @radix-ui/react-slotCopy and paste the following hooks into your hooks directory.
import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
export { useLazyRef };Copy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
interface DivProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
function getDataState(swapped: boolean) {
return swapped ? "on" : "off";
}
interface StoreState {
swapped: boolean;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
}
const StoreContext = React.createContext<Store | null>(null);
function useStore<T>(
selector: (state: StoreState) => T,
ogStore?: Store | null,
): T {
const contextStore = React.useContext(StoreContext);
const store = ogStore ?? contextStore;
if (!store) {
throw new Error(`\`useStore\` must be used within \`Swap\``);
}
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface SwapProps extends DivProps {
swapped?: boolean;
defaultSwapped?: boolean;
onSwappedChange?: (swapped: boolean) => void;
activationMode?: "click" | "hover";
animation?: "fade" | "rotate" | "flip" | "scale";
disabled?: boolean;
}
function Swap(props: SwapProps) {
const {
swapped: swappedProp,
defaultSwapped,
onSwappedChange,
activationMode = "click",
animation = "fade",
disabled,
asChild,
className,
onClick: onClickProp,
onMouseEnter: onMouseEnterProp,
onMouseLeave: onMouseLeaveProp,
onKeyDown: onKeyDownProp,
...rootProps
} = props;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => ({
swapped: swappedProp ?? defaultSwapped ?? false,
}));
const propsRef = useAsRef({
disabled,
onSwappedChange,
onClick: onClickProp,
onMouseEnter: onMouseEnterProp,
onMouseLeave: onMouseLeaveProp,
onKeyDown: onKeyDownProp,
});
const isClickMode = activationMode === "click";
const store = React.useMemo<Store>(() => {
return {
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
if (key === "swapped" && typeof value === "boolean") {
stateRef.current.swapped = value;
propsRef.current.onSwappedChange?.(value);
} else {
stateRef.current[key] = value;
}
store.notify();
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
};
}, [listenersRef, stateRef, propsRef]);
const swapped = useStore((state) => state.swapped, store);
useIsomorphicLayoutEffect(() => {
if (swappedProp !== undefined) {
store.setState("swapped", swappedProp);
}
}, [swappedProp]);
const onToggle = React.useCallback(() => {
if (propsRef.current.disabled) return;
store.setState("swapped", !store.getState().swapped);
}, [store, propsRef]);
const onClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
propsRef.current.onClick?.(event);
if (event.defaultPrevented || !isClickMode) return;
onToggle();
},
[propsRef, isClickMode, onToggle],
);
const onMouseEnter = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
propsRef.current.onMouseEnter?.(event);
if (
event.defaultPrevented ||
activationMode !== "hover" ||
propsRef.current.disabled
)
return;
store.setState("swapped", true);
},
[propsRef, activationMode, store],
);
const onMouseLeave = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
propsRef.current.onMouseLeave?.(event);
if (
event.defaultPrevented ||
activationMode !== "hover" ||
propsRef.current.disabled
)
return;
store.setState("swapped", false);
},
[propsRef, activationMode, store],
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
propsRef.current.onKeyDown?.(event);
if (event.defaultPrevented || !isClickMode || propsRef.current.disabled)
return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onToggle();
}
},
[propsRef, isClickMode, onToggle],
);
const RootPrimitive = asChild ? Slot : "div";
return (
<StoreContext.Provider value={store}>
<RootPrimitive
role={isClickMode ? "button" : undefined}
aria-pressed={isClickMode ? swapped : undefined}
aria-disabled={disabled}
data-slot="swap"
data-animation={animation}
data-state={getDataState(swapped)}
data-disabled={disabled ? "" : undefined}
tabIndex={isClickMode && !disabled ? 0 : undefined}
{...rootProps}
className={cn(
"relative inline-flex cursor-pointer select-none items-center justify-center data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onKeyDown={onKeyDown}
/>
</StoreContext.Provider>
);
}
function SwapOn(props: DivProps) {
const { asChild, className, ...onProps } = props;
const swapped = useStore((state) => state.swapped);
const OnPrimitive = asChild ? Slot : "div";
return (
<OnPrimitive
data-slot="swap-on"
data-state={getDataState(swapped)}
{...onProps}
className={cn(
"transition-all duration-300 data-[state=off]:absolute data-[state=off]:opacity-0 data-[state=on]:opacity-100 motion-reduce:transition-none",
"[*[data-animation=rotate]_&]:data-[state=off]:rotate-180 [*[data-animation=rotate]_&]:data-[state=on]:rotate-0 motion-reduce:[*[data-animation=rotate]_&]:data-[state=off]:rotate-0",
"[*[data-animation=flip]_&]:data-[state=off]:transform-[rotateY(180deg)] [*[data-animation=flip]_&]:data-[state=on]:transform-[rotateY(0deg)] motion-reduce:[*[data-animation=flip]_&]:data-[state=off]:transform-[rotateY(0deg)]",
"[*[data-animation=scale]_&]:data-[state=off]:scale-0 [*[data-animation=scale]_&]:data-[state=on]:scale-100 motion-reduce:[*[data-animation=scale]_&]:data-[state=off]:scale-100",
className,
)}
/>
);
}
function SwapOff(props: DivProps) {
const { asChild, className, ...offProps } = props;
const swapped = useStore((state) => state.swapped);
const OffPrimitive = asChild ? Slot : "div";
return (
<OffPrimitive
data-slot="swap-off"
data-state={getDataState(swapped)}
{...offProps}
className={cn(
"transition-all duration-300 data-[state=on]:absolute data-[state=off]:opacity-100 data-[state=on]:opacity-0 motion-reduce:transition-none",
"[*[data-animation=rotate]_&]:data-[state=off]:rotate-0 [*[data-animation=rotate]_&]:data-[state=on]:rotate-180 motion-reduce:[*[data-animation=rotate]_&]:data-[state=on]:rotate-0",
"[*[data-animation=flip]_&]:data-[state=off]:transform-[rotateY(0deg)] [*[data-animation=flip]_&]:data-[state=on]:transform-[rotateY(180deg)] motion-reduce:[*[data-animation=flip]_&]:data-[state=on]:transform-[rotateY(0deg)]",
"[*[data-animation=scale]_&]:data-[state=off]:scale-100 [*[data-animation=scale]_&]:data-[state=on]:scale-0 motion-reduce:[*[data-animation=scale]_&]:data-[state=on]:scale-100",
className,
)}
/>
);
}
export {
Swap,
SwapOn,
SwapOff,
//
useStore as useSwap,
//
type SwapProps,
};Update the import paths to match your project setup.
Layout
Import the parts, and compose them together.
import { Swap, SwapOn, SwapOff } from "@/components/ui/swap";
return (
<Swap>
<SwapOn />
<SwapOff />
</Swap>
)Examples
Animations
The swap component supports 4 different animation types: fade, rotate, flip, and scale.
import {
CheckIcon,
MoonIcon,
PauseIcon,
PlayIcon,
SunIcon,
Volume2Icon,
VolumeXIcon,
XIcon,
} from "lucide-react";
import { Swap, SwapOff, SwapOn } from "@/components/ui/swap";
export function SwapAnimationsDemo() {
return (
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
<div className="flex flex-col items-center gap-3">
<Swap
animation="fade"
className="size-12 rounded-lg border bg-muted/50"
>
<SwapOn>
<CheckIcon className="size-5" />
</SwapOn>
<SwapOff>
<XIcon className="size-5" />
</SwapOff>
</Swap>
<span className="text-center text-muted-foreground text-sm">Fade</span>
</div>
<div className="flex flex-col items-center gap-3">
<Swap
animation="rotate"
className="size-12 rounded-lg border bg-muted/50"
>
<SwapOn>
<SunIcon className="size-5" />
</SwapOn>
<SwapOff>
<MoonIcon className="size-5" />
</SwapOff>
</Swap>
<span className="text-center text-muted-foreground text-sm">
Rotate
</span>
</div>
<div className="flex flex-col items-center gap-3">
<Swap
animation="flip"
className="size-12 rounded-lg border bg-muted/50"
>
<SwapOn>
<PlayIcon className="size-5" />
</SwapOn>
<SwapOff>
<PauseIcon className="size-5" />
</SwapOff>
</Swap>
<span className="text-center text-muted-foreground text-sm">Flip</span>
</div>
<div className="flex flex-col items-center gap-3">
<Swap
animation="scale"
className="size-12 rounded-lg border bg-muted/50"
>
<SwapOn>
<Volume2Icon className="size-5" />
</SwapOn>
<SwapOff>
<VolumeXIcon className="size-5" />
</SwapOff>
</Swap>
<span className="text-center text-muted-foreground text-sm">Scale</span>
</div>
</div>
);
}API Reference
Swap
The main container component for swap functionality.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-state] | "on" | "off" |
[data-animation] | "fade" | "rotate" | "flip" | "scale" |
[data-disabled] | Present when the swap is disabled |
SwapOn
The content shown when the swap is in the swapped state.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-state] | "on" | "off" |
SwapOff
The content shown when the swap is in the default state.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-state] | "on" | "off" |
Accessibility
Keyboard Interactions
| Key | Description |
|---|---|
| Enter | Toggles the swap state when activation mode is 'click'. |
| Space | Toggles the swap state when activation mode is 'click'. |