Dice UI
Components

Swap

A component that swaps between two states with click or hover activation modes.

API
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/swap

Manual

Install the following dependencies:

npm install @radix-ui/react-slot

Copy 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 AttributeValue
[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 AttributeValue
[data-state]"on" | "off"

SwapOff

The content shown when the swap is in the default state.

Prop

Type

Data AttributeValue
[data-state]"on" | "off"

Accessibility

Keyboard Interactions

KeyDescription
EnterToggles the swap state when activation mode is 'click'.
SpaceToggles the swap state when activation mode is 'click'.

On this page