# Approve switch default

A shadcn/ui block from the [@craftui](https://www.craftui.space) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/craftui/approve-switch-default).

- Page, with a live preview: https://shadcn.exchange/r/craftui/approve-switch-default
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/craftui/approve-switch-default-light-d663e515cdad2c04.png
- Registry item: https://www.craftui.space/r/approve-switch-default.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.craftui.space/r/approve-switch-default.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://www.craftui.space/r/craftui-base.json`

## Files

### `components/craftui/blocks/approve-switch/default.tsx`

```tsx
"use client";

import { useState } from "react";
import {
  AnimatePresence,
  MotionConfig,
  motion,
  useReducedMotion,
  type Transition,
} from "motion/react";

/* ═════════════════════════════════════════════════════════
 * ANIMATION STORYBOARD — approve / reject switch
 *
 *  screen 1   two equal pills sit side by side: Reject, Approve.
 *  on click   THE PRESSED PILL EXPANDS IN PLACE — its colour floods, it
 *             stretches wide — while the other pill collapses into an undo
 *             circle.
 *  the word   "Reject" does not cross-fade into "Rejected". The base word is
 *             one text node that never unmounts, so it glides inside the
 *             widening pill; only the suffix — "ed", "d" — mounts, dropping
 *             in from above at the end of the word.
 *  undo       plays the whole thing in reverse.
 *
 * ONE NODE PER SLOT
 *   State changes a slot's size, colour and children — never its identity.
 *   So `layout` replaces `layoutId`: same element, Motion measures before and
 *   after and interpolates. The word needs no per-letter identity because it
 *   is never destroyed, and the badge recolours in place instead of
 *   cross-fading, because it is the same <svg> the whole way through.
 *
 * WHY popLayout
 *   During a swap the outgoing element leaves flow immediately, so the
 *   surviving sibling reflows to its final position on frame one and glides
 *   there, instead of waiting for the exit and then jumping.
 * ═════════════════════════════════════════════════════════ */

/* Deciding is a deliberate act, so it carries a little weight and a little
 * bounce; undo is a correction the user wants over with, so it snaps back
 * flat. Same family of spring either way — the block still reads as one
 * object, it just knows which direction it is travelling. */
const DECIDE: Transition = { type: "spring", duration: 0.42, bounce: 0.18 };
const UNDO: Transition = { type: "spring", duration: 0.3, bounce: 0 };

/* Content swaps are quicker and bounce-free: they are changes of substance,
 * not physical moves, and overshoot on text looks like a glitch. */
const SWAP: Transition = { type: "spring", duration: 0.32, bounce: 0 };

/* Leaving is shorter than arriving: while the pill shrinks, a label still
 * fading is wider than the box around it and reads as text escaping. */
const LEAVE: Transition = { type: "spring", duration: 0.16, bounce: 0 };

/* The glyph swap is the fastest thing in the block — a crossfade in a
 * fixed-size box, not a move. Short and bounce-free so the icon lands with
 * the pill instead of trailing it. */
const MARK: Transition = { type: "spring", duration: 0.2, bounce: 0 };

/* Colour is not a physical move, so it gets no bounce — but it is still a
 * spring, like everything else in the block: nothing here is a tween. A
 * bounce-free spring settles on a fill without the flicker an overshooting
 * one would give it. */
const TINT: Transition = { type: "spring", duration: 0.24, bounce: 0 };

/* Radius must be an inline pixel value: a layout animation scales the box,
 * and Motion can only undo the corner distortion when it knows the px. */
const RADIUS = 999;

/* ── Colour ────────────────────────────────────────────────
 * Every colour in the block is a `--as-*` token read through `var()`, so the
 * whole thing follows the theme with no JS in the loop.
 *
 * Motion drives the *shape* — that is where a spring earns its keep. It does
 * not drive the fills, for two reasons. It has to parse a colour to tween it,
 * and it cannot parse the `oklch()` the dark tokens are written in; and even
 * where it can, it resolves a `var()` target once, at the moment the
 * animation starts, then writes the literal into inline style — so a pill
 * that has already settled keeps the old theme's fill when the theme flips.
 *
 * Instead the resting pill is a plain painted background that never changes,
 * the decision tone is an overlay whose OPACITY Motion springs (opacity has
 * no parsing problem and no theme), and the two ink swaps are CSS transitions
 * on `color`, `fill` and `stroke` at the same 240ms.
 * ───────────────────────────────────────────────────────── */

/* Matches TINT's perceptual length, so the ink flips in step with the tone
 * flooding underneath it. */
const TINT_MS = "240ms";

/* The suffix is the whole point of the swap: it arrives from above and
 * leaves downward, so the past tense reads as dropping into place. */
const suffixVariants = {
  initial: { opacity: 0, y: "-0.55em", filter: "blur(4px)" },
  active: { opacity: 1, y: "0em", filter: "blur(0px)" },
  exit: { opacity: 0, y: "0.55em", filter: "blur(4px)", transition: LEAVE },
};

/* ── Reward layer ──────────────────────────────────────────
 * Two one-shot flourishes, mounted only while a slot is expanded and torn
 * down on undo, so they replay on every fresh decision.
 *
 * Approve gets confetti: a fan of particles thrown upward that arc over and
 * fall. Reject deliberately gets NO particles — a burst is a celebration, and
 * celebrating a rejection is the wrong feeling. It gets impact instead: a
 * short shake and a single ring pulsing out of the pill.
 *
 * Both are rare, deliberate, once-per-decision moments, which is the only
 * place this much motion is earned.
 * ───────────────────────────────────────────────────────── */

/* Deterministic pseudo-random: Math.random() would render different values on
 * the server and the client and blow up hydration. Seeded by index, this is
 * the same field every time — and a fixed field is easier to art-direct. */
function frac(n: number) {
  const x = Math.sin(n * 127.1) * 43758.5453;
  return x - Math.floor(x);
}

const CONFETTI_COLORS = [
  "#4cc85c",
  "#2fa8ff",
  "#ffc531",
  "#ff6f91",
  "#8b5cf6",
  /* The sixth is the ink, and it is the one that has to flip: the particles
   * leave the pill and land on the page, so a white chip is invisible on a
   * light stage and a black one on a dark stage. A var() is fine here — this
   * is a static `background`, not something Motion is tweening. */
  "var(--as-confetti-ink)",
];

const CONFETTI = Array.from({ length: 18 }, (_, i) => {
  /* Fan the throw across the upper half only — particles leaving downward
   * out of a pill look like debris, not celebration. */
  const angle = (-166 + (142 * i) / 17 + frac(i) * 12) * (Math.PI / 180);
  const dist = 68 + frac(i + 9) * 78;
  const round = frac(i + 3) > 0.55;
  return {
    dx: Math.cos(angle) * dist,
    dy: Math.sin(angle) * dist,
    drift: (frac(i + 5) - 0.5) * 46,
    fall: 96 + frac(i + 7) * 92,
    spin: (frac(i + 11) - 0.5) * 620,
    w: round ? 7 + frac(i + 2) * 3 : 5 + frac(i + 4) * 4,
    h: round ? 7 + frac(i + 2) * 3 : 9 + frac(i + 6) * 5,
    round,
    color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
    dur: 0.78 + frac(i + 13) * 0.34,
    delay: frac(i + 17) * 0.06,
  };
});

function Confetti() {
  return (
    /* A zero-size origin pinned to the pill's centre, so every particle's
       translation is measured from one point and nothing is clipped. */
    <span
      aria-hidden="true"
      className="pointer-events-none absolute top-1/2 left-1/2 z-[1] h-0 w-0"
    >
      {CONFETTI.map((p, i) => (
        <motion.span
          key={i}
          className="absolute top-0 left-0 block"
          style={{
            width: p.w,
            height: p.h,
            background: p.color,
            borderRadius: p.round ? "50%" : 2,
          }}
          initial={{ x: 0, y: 0, scale: 0.4, rotate: 0, opacity: 0 }}
          /* Three keyframes per axis: launch, apex, fall. Splitting x and y
           * lets gravity live only on y — the horizontal throw keeps its
           * ease-out while the vertical leg eases IN on the way down, which
           * is what makes it read as weight rather than a fade. */
          animate={{
            x: [0, p.dx, p.dx + p.drift],
            y: [0, p.dy, p.dy + p.fall],
            scale: [0.4, 1, 0.92],
            rotate: [0, p.spin],
            opacity: [0, 1, 1, 0],
          }}
          transition={{
            delay: p.delay,
            x: {
              duration: p.dur,
              times: [0, 0.42, 1],
              ease: [0.16, 1, 0.3, 1],
            },
            y: {
              duration: p.dur,
              times: [0, 0.42, 1],
              ease: ["easeOut", "easeIn"],
            },
            scale: { duration: p.dur, times: [0, 0.24, 1], ease: "easeOut" },
            rotate: { duration: p.dur, ease: "linear" },
            opacity: {
              duration: p.dur,
              times: [0, 0.06, 0.62, 1],
              ease: "linear",
            },
          }}
        />
      ))}
    </span>
  );
}

/* The rejection's answer to confetti: one ring leaving the pill and gone.
 * `boxShadow` rather than a border so it costs no layout and can be scaled.
 * The tone is passed as a token name, not a literal — nothing animates the
 * colour here, so it can stay live against the theme. */
function ImpactRing({ toneVar }: { toneVar: string }) {
  return (
    <motion.span
      aria-hidden="true"
      className="pointer-events-none absolute inset-0"
      style={{ borderRadius: RADIUS, boxShadow: `0 0 0 2px var(${toneVar})` }}
      initial={{ opacity: 0.5, scale: 1 }}
      animate={{ opacity: 0, scale: 1.14 }}
      transition={{ type: "spring", duration: 0.45, bounce: 0 }}
    />
  );
}

type SlotId = "reject" | "approve";
type Clicked = SlotId | null;

const SLOTS: {
  id: SlotId;
  label: string;
  suffix: string;
  toneVar: "--as-reject" | "--as-approve";
  glyph: "cross" | "check";
}[] = [
  {
    id: "reject",
    label: "Reject",
    suffix: "ed",
    toneVar: "--as-reject",
    glyph: "cross",
  },
  {
    id: "approve",
    label: "Approve",
    suffix: "d",
    toneVar: "--as-approve",
    glyph: "check",
  },
];

/* Shared sizing. The badge box and the collapsed circle have to be the same
 * measurement as the pill's height, so the three clamps are written once. */
const PILL_H = "clamp(56px,6.4vw,72px)";
const MARK_SIZE = "clamp(26px,2.9vw,38px)";

/* One <svg> for the life of the slot. Idle it is a tinted disc with a white
 * glyph; expanded the two swap — animated, not re-rendered, so the badge
 * never blinks. Only the slot that LOSES has to change glyph, and that one
 * swap is the single crossfade left in the component. */
function SlotMark({
  glyph,
  expanded,
  collapsed,
}: {
  glyph: "cross" | "check";
  expanded: boolean;
  collapsed: boolean;
}) {
  /* --as-tone is set on the button, so the disc reads its slot's own colour
   * without either being passed down as a literal. Expanded, the disc and
   * the glyph trade places. */
  const disc = expanded ? "var(--as-on)" : "var(--as-tone)";
  const ink = expanded ? "var(--as-tone)" : "var(--as-on)";
  const path =
    glyph === "check"
      ? "M6.8 12.4 10.4 16 17.2 8.8"
      : "M8.2 8.2 15.8 15.8M15.8 8.2 8.2 15.8";

  /* No `mode` — the two glyphs are stacked in the same fixed box and fade
     through each other. `mode="wait"` would hold the incoming one back until
     the outgoing had finished leaving, which reads as a late-arriving icon. */
  return (
    <AnimatePresence initial={false}>
      {collapsed ? (
        <motion.svg
          key="undo"
          className="absolute inset-0 block h-full w-full"
          viewBox="0 0 24 24"
          aria-hidden="true"
          initial={{ opacity: 0, filter: "blur(4px)", scale: 0.9 }}
          animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
          exit={{ opacity: 0, filter: "blur(4px)", scale: 0.9 }}
          transition={MARK}
        >
          <path
            d="M9 6 4.5 10.5 9 15"
            fill="none"
            stroke="currentColor"
            strokeWidth="2.6"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
          <path
            d="M4.5 10.5h9a5.5 5.5 0 0 1 0 11H10"
            fill="none"
            stroke="currentColor"
            strokeWidth="2.6"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </motion.svg>
      ) : (
        <motion.svg
          key="glyph"
          className="absolute inset-0 block h-full w-full"
          viewBox="0 0 24 24"
          aria-hidden="true"
          initial={{ opacity: 0, filter: "blur(4px)", scale: 0.9 }}
          animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
          exit={{ opacity: 0, filter: "blur(4px)", scale: 0.9 }}
          transition={MARK}
        >
          <circle
            cx="12"
            cy="12"
            r="12"
            className="transition-[fill] ease-out"
            style={{ fill: disc, transitionDuration: TINT_MS }}
          />
          <path
            d={path}
            fill="none"
            className="transition-[stroke] ease-out"
            style={{ stroke: ink, transitionDuration: TINT_MS }}
            strokeWidth="2.6"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </motion.svg>
      )}
    </AnimatePresence>
  );
}

export default function ApproveSwitch({ className }: { className?: string }) {
  const [clicked, setClicked] = useState<Clicked>(null);
  /* Confetti and a shake are decoration, so under reduced motion they are not
   * softened — they are simply not mounted. The decision still reads without
   * them; the pill and the word carry it. */
  const reduced = useReducedMotion();
  /* Returning to idle IS the undo, whichever slot the press came from. */
  const travel = clicked === null ? UNDO : DECIDE;

  return (
    <MotionConfig transition={travel} reducedMotion="user">
      <div
        className={`flex min-h-full w-full items-center justify-center p-[clamp(16px,3vw,36px)] ${className ?? ""}`}
      >
        {/* The row is the shared frame both screens live in: the two slots
            keep their order across states, so the morph only ever has to
            change size and colour. */}
        <div className="flex items-center gap-[clamp(10px,1.4vw,18px)]">
          {SLOTS.map((slot) => {
            const expanded = clicked === slot.id;
            const collapsed = clicked !== null && !expanded;

            const celebrate = expanded && slot.id === "approve" && !reduced;
            const rebuff = expanded && slot.id === "reject" && !reduced;

            return (
              /* The wrapper does not participate in the layout animation, so
                 it is a stable frame to hang the flourishes on — and shaking
                 it leaves the button's own transform free for `layout`, which
                 would otherwise fight an `x` animation on the same node. */
              <motion.span
                key={slot.id}
                className="relative inline-flex"
                animate={{ x: rebuff ? [0, -7, 6, -4, 3, -1.5, 0] : 0 }}
                transition={{ duration: 0.4, ease: "easeOut" }}
              >
                <AnimatePresence>
                  {rebuff && <ImpactRing key="ring" toneVar={slot.toneVar} />}
                </AnimatePresence>
                <AnimatePresence>
                  {celebrate && <Confetti key="confetti" />}
                </AnimatePresence>
                <motion.button
                  type="button"
                  layout
                  style={{
                    borderRadius: RADIUS,
                    height: PILL_H,
                    /* The slot's own decision colour, published to every
                       descendant — the tone overlay and the badge disc both
                       read it, so neither has to be handed a literal. */
                    ["--as-tone" as string]: `var(${slot.toneVar})`,
                    color: expanded ? "var(--as-on)" : "var(--as-pill-ink)",
                    transitionDuration: TINT_MS,
                  }}
                  transition={{ layout: travel }}
                  whileTap={{ scale: 0.96 }}
                  onClick={() => setClicked(clicked === null ? slot.id : null)}
                  aria-label={collapsed ? "Undo decision" : undefined}
                  aria-pressed={expanded}
                  className={[
                    /* The resting pill is painted here and never changes —
                       the decision tone floods in as an overlay on top of it,
                       so this background is what is visible at rest, before
                       hydration, and behind every state. */
                    "relative flex cursor-pointer items-center justify-center gap-3 border-0 bg-[var(--as-pill)] transition-[color] ease-out select-none",
                    "text-[clamp(18px,1.9vw,26px)] leading-none font-bold tracking-[-0.01em] whitespace-nowrap",
                    /* Clips the label on its way out, so nothing is ever
                       visible outside the pill while the pill shrinks. */
                    "overflow-hidden [-webkit-tap-highlight-color:transparent]",
                    "outline-none focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-current",
                    /* A fixed expanded width keeps the morph from snapping to
                       content width when the suffix lands a beat after the
                       pill has finished growing. Collapsed is a circle, so
                       width has to equal the shared height. */
                    expanded
                      ? "w-[clamp(280px,34vw,420px)] p-0"
                      : collapsed
                        ? "w-[clamp(56px,6.4vw,72px)] gap-0 p-0"
                        : "px-[clamp(22px,2.6vw,34px)]",
                  ].join(" ")}
                >
                  {/* The decision tone. An overlay rather than a background
                      on the button itself: opacity is the one visual channel
                      Motion can spring without parsing a colour, which keeps
                      the fill a live `var()` and the flood a real spring.
                      rounded-[inherit] picks up the pill's px radius, so it
                      is corrected by the same layout projection. */}
                  <motion.span
                    aria-hidden="true"
                    className="pointer-events-none absolute inset-0 rounded-[inherit] bg-[var(--as-tone)]"
                    initial={false}
                    animate={{ opacity: expanded ? 1 : 0 }}
                    transition={TINT}
                  />

                  {/* A fixed box the two glyphs are stacked inside, so they
                      fade through each other simultaneously instead of taking
                      turns, and neither reflows the pill while swapping.
                      layout="position" keeps it centred while the pill grows
                      instead of being stretched by the parent's morph. */}
                  <motion.span
                    layout="position"
                    className="relative z-10 flex flex-none items-center justify-center"
                    style={{ width: MARK_SIZE, height: MARK_SIZE }}
                  >
                    <SlotMark
                      glyph={slot.glyph}
                      expanded={expanded}
                      collapsed={collapsed}
                    />
                  </motion.span>

                  {/* popLayout takes the label out of flow the instant it
                      starts leaving, so the collapsing pill shrinks around it
                      instead of waiting for the fade and then snapping. */}
                  <AnimatePresence mode="popLayout" initial={false}>
                    {!collapsed && (
                      <motion.span
                        key="label"
                        layout="position"
                        className="relative z-10 inline-flex items-baseline whitespace-pre"
                        initial={{ opacity: 0, filter: "blur(4px)" }}
                        animate={{ opacity: 1, filter: "blur(0px)" }}
                        exit={{
                          opacity: 0,
                          filter: "blur(4px)",
                          transition: LEAVE,
                        }}
                        transition={SWAP}
                      >
                        {slot.label}
                        <AnimatePresence mode="popLayout" initial={false}>
                          {expanded && (
                            /* inline-block so the suffix can be moved on y;
                               overflow stays visible so descenders are never
                               clipped. */
                            <motion.span
                              key="suffix"
                              className="inline-block whitespace-pre"
                              variants={suffixVariants}
                              initial="initial"
                              animate="active"
                              exit="exit"
                              transition={SWAP}
                            >
                              {slot.suffix}
                            </motion.span>
                          )}
                        </AnimatePresence>
                      </motion.span>
                    )}
                  </AnimatePresence>
                </motion.button>
              </motion.span>
            );
          })}
        </div>
      </div>
    </MotionConfig>
  );
}
```
