Domphy

animatedThemeToggler

A Effects block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call animatedThemeToggler() with no arguments for a working demo, or edit the code below live.

Implementation notes

Full clean-room reimplementation, not viewed against upstream source. Icon button (buttonGhost, ~themeSpacing(9) square) swaps sun/moon glyphs (own hand-built SVGs: rays+circle sun, two-circle SVG-mask crescent moon — not any specific icon library's path data) via a reactive display style keyed off a theme State. On click it uses the real View Transitions API: document.startViewTransition() snapshots old/new frames, then a WAAPI clip-path animation is applied to the browser's own ::view-transition-new(root) pseudo-element (via el.animate(keyframes,{pseudoElement:...})) — no manual DOM screenshot cloning. All 7 shapes implemented: circle uses exact circle(r at x y); square/diamond/hexagon/triangle use a principled regular-polygon apothem bound (radius = cornerDistance/cos(pi/sides)) that's mathematically guaranteed to fully cover the viewport since a convex shape containing all 4 viewport corners contains the whole (convex) viewport rectangle; rectangle uses an exact axis-aligned half-extent fit; star conservatively treats itself as just its inner pentagon (ignoring the outer points' extra reach) for the same guarantee. origin 'button'|'center' and duration are both wired through. Falls back to an instant, unanimated theme swap when document.startViewTransition is unavailable (checked via feature-detection), exactly as the spec requires. theme prop accepts a ValueOrState so passing a caller-owned State two-way-binds into an external store; onThemeChange covers imperative stores (e.g. localStorage) instead. In the course of writing this component's test, found and fixed a genuine pre-existing bug in @domphy/core's ElementNode.remove()/ElementList.remove() (packages/core/src/classes/ElementNode.ts, ElementList.ts): both re-read _hooks.BeforeRemove AFTER invoking it to check its arity, but a synchronous 2-arg _onBeforeRemove hook (exactly what @domphy/ui's motion() patch does when no exit frame is given) can trigger _dispose() inline, which clears _hooks to {} before that re-read, throwing instead of completing removal. Fixed by capturing the hook reference before invocation (both files), added 2 regression tests to packages/core/tests/lifecycle.test.ts, and reran the full core (156) and ui (345) suites green before rebuilding core's dist that this package's tests consume.

Status: ported · Reference: Magic UI original

// Magic UI "Animated Theme Toggler" — clean-room reimplementation.
//
// A small icon button that flips between light/dark theme while a shape
// (a circle, by default) wipes outward from the button — or from the
// viewport center — to reveal the new color scheme. Implemented with the
// native View Transitions API: `document.startViewTransition()` snapshots
// the before/after DOM states for us, and the reveal itself is a plain Web
// Animations API `clip-path` animation applied to the browser-managed
// `::view-transition-new(root)` pseudo-element — no manual before/after
// screenshot cloning is needed. Browsers without View Transitions support
// fall back to an instant, unanimated theme swap, per spec.
//
// The component itself is theme-agnostic: it does not own a global
// light/dark switch. It reports the toggled value through `theme` (pass a
// `State` to wire it straight into an external store) and/or the
// `onThemeChange` callback, and it is the caller's responsibility to react to
// that (e.g. flip a `data-color-scheme` attribute on `<html>`) inside the
// `document.startViewTransition()` update — which is exactly what happens
// here since `onThemeChange`/`theme.set()` are invoked from that callback.
//
// Implemented purely from the block's public functional/visual spec — no
// upstream Magic UI source was viewed or copied.

import type {
  DomphyElement,
  ElementNode,
  Listener,
  StyleObject,
  ValueOrState,
} from "@domphy/core";
import { toState } from "@domphy/core";
import { buttonGhost } from "@domphy/ui";
import { themeSpacing } from "@domphy/theme";

export type ThemeTogglerTheme = "light" | "dark";

export type ThemeWipeVariant =
  | "circle"
  | "square"
  | "triangle"
  | "diamond"
  | "rectangle"
  | "hexagon"
  | "star";

export interface AnimatedThemeTogglerProps {
  /** Controlled current theme. Pass a `State<"light"|"dark">` to wire this into
   * an external theme store — writes made by this component go straight
   * through to that same state. Defaults to an internal `"light"` state so
   * the component works as a standalone demo. */
  theme?: ValueOrState<ThemeTogglerTheme>;
  /** Called with the new theme value at the moment the swap happens (inside
   * the same callback that drives the wipe), for callers that prefer an
   * imperative external store (e.g. writing to `localStorage`) over passing
   * a `State`. */
  onThemeChange?: (nextTheme: ThemeTogglerTheme) => void;
  /** Shape the reveal wipes outward in. Defaults to `"circle"`. */
  variant?: ThemeWipeVariant;
  /** Wipe duration in ms. Defaults to `400`. */
  duration?: number;
  /** Where the wipe originates from: the button's own screen position, or
   * the viewport center. Defaults to `"button"`. */
  origin?: "button" | "center";
  /** Accessible label for the button. Defaults to `"Toggle theme"`. */
  ariaLabel?: string;
  /** Custom glyph shown in light mode (swapped for `darkIcon` in dark mode).
   * Defaults to a sun glyph. */
  lightIcon?: DomphyElement;
  /** Custom glyph shown in dark mode. Defaults to a crescent-moon glyph. */
  darkIcon?: DomphyElement;
  style?: StyleObject;
}

let themeTogglerInstanceCounter = 0;

/** Simple radiating-rays sun glyph, painted via `fill="currentColor"` /
 * `stroke="currentColor"` so it inherits the button's icon-color idiom
 * (same technique as this package's other decorative glyphs). */
function sunGlyph(): DomphyElement {
  return {
    svg: [
      { circle: null, cx: "12", cy: "12", r: "4.5" },
      {
        path: null,
        d: "M12 2v2.5M12 19.5V22M4.93 4.93l1.77 1.77M17.3 17.3l1.77 1.77M2 12h2.5M19.5 12H22M4.93 19.07l1.77-1.77M17.3 6.7l1.77-1.77",
        fill: "none",
        stroke: "currentColor",
        strokeWidth: "2",
        strokeLinecap: "round",
      },
    ],
    viewBox: "0 0 24 24",
    xmlns: "http://www.w3.org/2000/svg",
    fill: "currentColor",
    role: "img",
    ariaHidden: "true",
  } as DomphyElement;
}

/** Crescent-moon glyph built from two plain circles combined through an SVG
 * `<mask>` (a full disc minus an offset disc) rather than reproducing any
 * specific icon library's hand-authored path data. */
function moonGlyph(maskId: string): DomphyElement {
  return {
    svg: [
      {
        defs: [
          {
            mask: [
              { circle: null, cx: "12", cy: "12", r: "8", fill: "white" },
              { circle: null, cx: "16.5", cy: "8.5", r: "7", fill: "black" },
            ],
            id: maskId,
          } as DomphyElement,
        ],
      } as DomphyElement,
      {
        circle: null,
        cx: "12",
        cy: "12",
        r: "8",
        fill: "currentColor",
        mask: `url(#${maskId})`,
      },
    ],
    viewBox: "0 0 24 24",
    xmlns: "http://www.w3.org/2000/svg",
    role: "img",
    ariaHidden: "true",
  } as DomphyElement;
}

/** Regular-polygon vertex offsets (in px, relative to the wipe's origin
 * point) for a shape with `sides` equally-spaced vertices, sized so that
 * even a viewport corner sitting exactly on an edge midpoint (the worst
 * case for a regular polygon) is still covered — the standard
 * `radius = coverage / cos(pi / sides)` apothem bound. */
function regularPolygonOffsets(
  vertexAnglesDeg: number[],
  sides: number,
  coverageRadius: number,
  radiusMultiplier = 1,
): Array<[number, number]> {
  const radius = (coverageRadius / Math.cos(Math.PI / sides)) * radiusMultiplier;
  return vertexAnglesDeg.map((angleDeg) => {
    const angleRad = (angleDeg * Math.PI) / 180;
    return [Math.cos(angleRad) * radius, Math.sin(angleRad) * radius];
  });
}

function polygonKeyframePair(
  offsets: Array<[number, number]>,
  originX: number,
  originY: number,
): [string, string] {
  const collapsed = offsets.map(() => `${originX}px ${originY}px`).join(", ");
  const expanded = offsets
    .map(([dx, dy]) => `${originX + dx}px ${originY + dy}px`)
    .join(", ");
  return [`polygon(${collapsed})`, `polygon(${expanded})`];
}

/** Builds the `[from, to]` `clip-path` pair for the wipe's chosen shape,
 * guaranteed to fully cover the viewport by the end of the animation
 * regardless of where the origin point sits. */
function buildWipeClipPathKeyframes(
  variant: ThemeWipeVariant,
  originX: number,
  originY: number,
  viewportWidth: number,
  viewportHeight: number,
): [string, string] {
  const coverX = Math.max(originX, viewportWidth - originX);
  const coverY = Math.max(originY, viewportHeight - originY);
  const coverDiag = Math.hypot(coverX, coverY);

  if (variant === "circle") {
    return [
      `circle(0px at ${originX}px ${originY}px)`,
      `circle(${coverDiag}px at ${originX}px ${originY}px)`,
    ];
  }

  if (variant === "rectangle") {
    // A true axis-aligned rectangle: half-extents sized independently per
    // axis so it exactly reaches the farthest edge on each axis (no
    // apothem bound needed — the shape's own corners are the coverage
    // target here).
    const offsets: Array<[number, number]> = [
      [coverX, coverY],
      [-coverX, coverY],
      [-coverX, -coverY],
      [coverX, -coverY],
    ];
    return polygonKeyframePair(offsets, originX, originY);
  }

  if (variant === "star") {
    // Conservative bound: treat the star as if it were only its inner
    // pentagon (ignoring the extra reach of the outer points, which can
    // only add coverage, never remove it).
    const innerOffsets = regularPolygonOffsets(
      Array.from({ length: 5 }, (_, index) => -90 + index * 72 + 36),
      5,
      coverDiag,
    );
    const innerRadius = Math.hypot(innerOffsets[0][0], innerOffsets[0][1]);
    const outerRadius = innerRadius * 2;
    const starOffsets: Array<[number, number]> = Array.from({ length: 10 }, (_, index) => {
      const angleDeg = -90 + index * 36;
      const radius = index % 2 === 0 ? outerRadius : innerRadius;
      const angleRad = (angleDeg * Math.PI) / 180;
      return [Math.cos(angleRad) * radius, Math.sin(angleRad) * radius];
    });
    return polygonKeyframePair(starOffsets, originX, originY);
  }

  const offsets: Array<[number, number]> = (() => {
    switch (variant) {
      case "square":
        return regularPolygonOffsets([45, 135, 225, 315], 4, coverDiag);
      case "diamond":
        return regularPolygonOffsets([0, 90, 180, 270], 4, coverDiag);
      case "hexagon":
        return regularPolygonOffsets([0, 60, 120, 180, 240, 300], 6, coverDiag);
      case "triangle":
        return regularPolygonOffsets([-90, 30, 150], 3, coverDiag);
      default:
        return regularPolygonOffsets([45, 135, 225, 315], 4, coverDiag);
    }
  })();
  return polygonKeyframePair(offsets, originX, originY);
}

/**
 * A small icon button that flips between light/dark theme behind an
 * expanding geometric wipe (circle by default) originating from the button
 * itself or the screen center, using the native View Transitions API where
 * supported and falling back to an instant swap otherwise. Call with no
 * arguments for a working demo (an internally-managed `"light"` state).
 */
function animatedThemeToggler(
  props: AnimatedThemeTogglerProps = {},
): DomphyElement<"button"> {
  const {
    variant = "circle",
    duration = 400,
    origin = "button",
    ariaLabel = "Toggle theme",
  } = props;

  const theme = toState(props.theme ?? "light", "theme");
  const onThemeChange = props.onThemeChange;

  const instanceId = ++themeTogglerInstanceCounter;
  const moonMaskId = `domphy-theme-toggler-moon-mask-${instanceId}`;

  const lightIcon = props.lightIcon ?? sunGlyph();
  const darkIcon = props.darkIcon ?? moonGlyph(moonMaskId);

  let buttonElement: HTMLButtonElement | null = null;

  function handleToggle(): void {
    const nextTheme: ThemeTogglerTheme = theme.get() === "dark" ? "light" : "dark";

    const applyTheme = () => {
      theme.set(nextTheme);
      onThemeChange?.(nextTheme);
    };

    // Small icon bounce plays regardless of View Transitions support — it is
    // a cheap, local button-hover-style flourish, not the page-wide wipe.
    if (buttonElement && typeof buttonElement.animate === "function") {
      buttonElement.animate(
        [
          { transform: "scale(1) rotate(0deg)" },
          { transform: "scale(0.75) rotate(-25deg)" },
          { transform: "scale(1) rotate(0deg)" },
        ],
        { duration: Math.min(duration, 350), easing: "ease-out" },
      );
    }

    const supportsViewTransition =
      typeof document !== "undefined" &&
      typeof document.startViewTransition === "function" &&
      typeof document.documentElement.animate === "function" &&
      typeof window !== "undefined";

    if (!supportsViewTransition || !buttonElement) {
      // Graceful fallback: instant theme swap, no wipe animation.
      applyTheme();
      return;
    }

    const buttonRect = buttonElement.getBoundingClientRect();
    const originX =
      origin === "center" ? window.innerWidth / 2 : buttonRect.left + buttonRect.width / 2;
    const originY =
      origin === "center" ? window.innerHeight / 2 : buttonRect.top + buttonRect.height / 2;
    const [fromClipPath, toClipPath] = buildWipeClipPathKeyframes(
      variant,
      originX,
      originY,
      window.innerWidth,
      window.innerHeight,
    );

    const transition = document.startViewTransition(applyTheme);
    transition.ready
      .then(() => {
        document.documentElement.animate(
          [{ clipPath: fromClipPath }, { clipPath: toClipPath }],
          { duration, easing: "ease-out", pseudoElement: "::view-transition-new(root)" },
        );
      })
      .catch(() => {
        // The transition was skipped/interrupted (e.g. a rapid re-click).
        // `applyTheme()` already ran synchronously inside
        // `startViewTransition`'s update callback, so the theme itself is
        // already correct even without the wipe animation playing.
      });
  }

  const iconStyle = (visibleWhen: ThemeTogglerTheme): StyleObject => ({
    position: "absolute",
    inset: 0,
    display: (listener: Listener) => (theme.get(listener) === visibleWhen ? "flex" : "none"),
    alignItems: "center",
    justifyContent: "center",
  });

  return {
    button: [
      {
        span: [lightIcon],
        ariaHidden: "true",
        style: iconStyle("light"),
      } as DomphyElement,
      {
        span: [darkIcon],
        ariaHidden: "true",
        style: iconStyle("dark"),
      } as DomphyElement,
    ],
    type: "button",
    ariaLabel,
    onClick: () => handleToggle(),
    $: [buttonGhost({ color: "neutral" })],
    _onMount: (node: ElementNode) => {
      buttonElement = node.domElement as HTMLButtonElement;
    },
    _onRemove: () => {
      buttonElement = null;
    },
    style: {
      position: "relative",
      width: themeSpacing(9),
      height: themeSpacing(9),
      ...(props.style ?? {}),
    },
  } as DomphyElement<"button">;
}

export { animatedThemeToggler };

← Back to Magic UI catalog