Switch Eclipse

Switch

A binary switch drawn as an eclipse — a dark occluding disc slides across a bright sun disc inside a 56x28 track, forming a computed crescent at the midpoint and a thin corona ring once fully on, while the track's ambient tint darkens smoothly with occlusion.

Install
npx shadcn add https://design.helpmarq.com/r/switch-eclipse.json
Source
registry/core/switch-eclipse/component.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// UmbraToggle — a binary switch drawn as an eclipse. A small bright "sun"
// disc sits fixed inside a 56x28 track; a dark "occluder" disc slides across
// it. The overlap between the two circles is pure geometry — the occluder is
// just a second circle in the same SVG, painted with the track's own
// background color so it visually "cuts into" the sun rather than needing a
// mask, and at full occlusion a thin corona (a 1px --foreground ring plus a
// soft blur glow) appears around the sun's rim. Occlusion fraction (0 = off,
// 1 = on) is the single driver: it positions the occluder, interpolates the
// track's ambient tint, and fades the corona in near fraction=1. Everything
// per-frame during a drag is written straight to refs (no React state), and
// discrete transitions (click, keyboard) run the same writer on a short CSS
// transition instead of a rAF loop.
// ---------------------------------------------------------------------------

export interface UmbraToggleProps {
  checked?: boolean;
  defaultChecked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  /** accessible name; falls back to the visible Light/Dark label pair */
  "aria-label"?: string;
  className?: string;
}

const TRACK_W = 56;
const TRACK_H = 28;
const SUN_R = 8;
const OCCLUDER_R = 9;
const INSET = 6; // sun center offset from each edge at rest
const TRAVEL = TRACK_W - INSET * 2; // distance the occluder center travels
const TRANSITION_MS = 220;
const BREATHE_MS = 900;

function lerp(a: number, b: number, t: number) {
  return a + (b - a) * t;
}

function applyFrame(
  occluderEl: SVGCircleElement | null,
  trackEl: HTMLButtonElement | null,
  coronaEl: SVGCircleElement | null,
  fraction: number,
  transition: boolean
) {
  if (occluderEl) {
    // rests clear of the sun when off (fraction 0) and slides fully onto
    // it — same center, larger radius — when on (fraction 1), so "off"
    // shows the bare sun and "on" is a total eclipse with the corona
    const cx = lerp(INSET + TRAVEL, INSET, fraction);
    occluderEl.style.transition = transition
      ? `cx ${TRANSITION_MS}ms cubic-bezier(0.16,1,0.3,1), fill ${TRANSITION_MS}ms linear`
      : "none";
    occluderEl.setAttribute("cx", String(cx));
  }
  if (trackEl) {
    // ambient tint darkens smoothly with occlusion — interpolate toward a
    // darker mix of the track's own border token rather than a hardcoded
    // color. The occluder disc's fill reads this same custom property (it
    // inherits down through the SVG), so it always matches the track's
    // current backing color exactly and reads as "cutting into" the sun via
    // plain paint order rather than a mask.
    const tint = `color-mix(in srgb, var(--border) ${100 - fraction * 55}%, #000 ${fraction * 55}%)`;
    trackEl.style.transition = transition
      ? `background-color ${TRANSITION_MS}ms linear, border-color ${TRANSITION_MS}ms linear`
      : "none";
    trackEl.style.backgroundColor = tint;
    trackEl.style.setProperty("--umbra-tint", tint);
    // Near full occlusion (the same fade window the corona uses) the
    // occluder is otherwise painted in the exact tint the track background
    // was just set to, so the "moon" reads as a hole rather than a disc —
    // in light theme especially, where the tint never gets dark enough for
    // the blurred corona ring alone to read against it. Mix a sliver of the
    // themed --umbra-bright token (always the *light* token for the current
    // theme, same trick the corona stroke already uses) into the occluder's
    // own fill so it stays a legible disc instead of disappearing into the
    // background it's built from. At fraction <= 0.72 this is a 0% mix, i.e.
    // exactly `tint` — unchanged from before.
    const moonMix = Math.max(0, (fraction - 0.72) / 0.28) * 22;
    const moon =
      moonMix > 0 ? `color-mix(in srgb, ${tint} ${100 - moonMix}%, var(--umbra-bright) ${moonMix}%)` : tint;
    trackEl.style.setProperty("--umbra-moon", moon);
  }
  if (coronaEl) {
    // A running `breathe` keyframe animation outranks this opacity write in
    // the cascade (animations win over the specified/inline value for the
    // property they animate) — without cancelling it first, a toggle that
    // lands mid-breathe keeps the corona visibly pulsing on the "off" sun
    // for the rest of the 900ms, instead of fading out immediately.
    coronaEl.style.animation = "none";
    const coronaOpacity = Math.max(0, (fraction - 0.72) / 0.28);
    coronaEl.style.transition = transition ? `opacity ${TRANSITION_MS}ms linear` : "none";
    coronaEl.style.opacity = String(coronaOpacity);
  }
}

export function UmbraToggle({
  checked,
  defaultChecked = false,
  onCheckedChange,
  "aria-label": ariaLabel,
  className = "",
}: UmbraToggleProps) {
  const [uncontrolled, setUncontrolled] = useState(defaultChecked);
  const isControlled = checked !== undefined;
  const value = isControlled ? checked : uncontrolled;

  const trackRef = useRef<HTMLButtonElement | null>(null);
  const occluderRef = useRef<SVGCircleElement | null>(null);
  const coronaRef = useRef<SVGCircleElement | null>(null);

  const reducedRef = useRef(false);
  const dragFractionRef = useRef(value ? 1 : 0);
  const pointerDownRef = useRef(false);
  const movedRef = useRef(false);
  const startXRef = useRef(0);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    const onChange = () => {
      reducedRef.current = mq.matches;
    };
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  // reflect `value` whenever it changes and we're not mid-drag
  useEffect(() => {
    if (pointerDownRef.current) return;
    dragFractionRef.current = value ? 1 : 0;
    applyFrame(occluderRef.current, trackRef.current, coronaRef.current, dragFractionRef.current, !reducedRef.current);
  }, [value]);

  const commit = useCallback(
    (next: boolean) => {
      if (!isControlled) setUncontrolled(next);
      onCheckedChange?.(next);
    },
    [isControlled, onCheckedChange]
  );

  const toggle = useCallback(() => {
    commit(!value);
  }, [commit, value]);

  // Pointer down always arms; only real movement past a small threshold
  // (movedRef) counts as a drag. A plain click/tap (no movement) just flips
  // the current value — a drag instead snaps to whichever side the pointer
  // ended up closer to, so click and drag are two coherent gestures rather
  // than one continuous-position model fighting a discrete toggle.
  const updateFromPointer = (clientX: number) => {
    const track = trackRef.current;
    if (!track) return;
    const rect = track.getBoundingClientRect();
    // the occluder tracks the pointer directly (1:1) rather than the pointer
    // driving `fraction` straight — fraction is ON-ness (1 = occluder at
    // INSET, over the sun), which is the *opposite* end of the track from
    // where the occluder rests when off, so deriving fraction from a plain
    // left-to-right t would move the disc backwards relative to the pointer
    const cx = Math.min(INSET + TRAVEL, Math.max(INSET, clientX - rect.left));
    const fraction = 1 - (cx - INSET) / TRAVEL;
    dragFractionRef.current = fraction;
    applyFrame(occluderRef.current, trackRef.current, coronaRef.current, fraction, false);
  };

  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
    const track = trackRef.current;
    if (!track) return;
    pointerDownRef.current = true;
    movedRef.current = false;
    startXRef.current = e.clientX;
    track.setPointerCapture(e.pointerId);
  };

  const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
    if (!pointerDownRef.current) return;
    if (!movedRef.current && Math.abs(e.clientX - startXRef.current) > 3) {
      movedRef.current = true;
    }
    if (movedRef.current) updateFromPointer(e.clientX);
  };

  const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
    if (!pointerDownRef.current) return;
    pointerDownRef.current = false;
    trackRef.current?.releasePointerCapture(e.pointerId);
    const next = movedRef.current ? dragFractionRef.current >= 0.5 : !value;
    // Only paint the guessed `next` state when it's actually going to become
    // `value` — a controlled parent can ignore `onCheckedChange` and leave
    // `value` unchanged, and painting the guess anyway would desync the
    // visible disc from aria-checked. When `next` isn't adopted (or a drag
    // released back on the side it started from, so `[value]` never refires),
    // settle back to the real current value instead.
    if (next !== value) commit(next);
    else applyFrame(occluderRef.current, trackRef.current, coronaRef.current, value ? 1 : 0, !reducedRef.current);
  };

  // A cancelled gesture (browser-initiated scroll takeover, stylus lift,
  // system interruption) is not a release — nothing was decided, so it must
  // never commit. Just disarm and repaint from the real value.
  const onPointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
    if (!pointerDownRef.current) return;
    pointerDownRef.current = false;
    trackRef.current?.releasePointerCapture(e.pointerId);
    applyFrame(occluderRef.current, trackRef.current, coronaRef.current, value ? 1 : 0, !reducedRef.current);
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    if (e.repeat) return; // holding the key shouldn't machine-gun the toggle
    if (e.key === " " || e.key === "Enter") {
      e.preventDefault();
      toggle();
    }
  };

  const breathe = () => {
    const el = coronaRef.current;
    if (!el || reducedRef.current || !value) return;
    el.style.animation = "none";
    el.getBoundingClientRect(); // force reflow so re-adding the animation restarts it
    el.style.animation = `ns-umbra-breathe ${BREATHE_MS}ms ease-in-out`;
  };

  return (
    <span className={`inline-flex items-center gap-2.5 ${className}`}>
      <style>{CSS}</style>
      <span
        aria-hidden="true"
        className={`font-mono text-xs uppercase tracking-wide transition-colors ${
          !value ? "text-foreground" : "text-muted"
        }`}
      >
        Light
      </span>

      <button
        ref={trackRef}
        type="button"
        role="switch"
        aria-checked={value}
        aria-label={ariaLabel ?? "Toggle light and dark"}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerCancel}
        onKeyDown={onKeyDown}
        onMouseEnter={breathe}
        className="ns-umbra-track relative shrink-0 overflow-hidden rounded-full border border-border transition-colors hover:border-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
        style={{
          width: TRACK_W,
          height: TRACK_H,
          touchAction: "none",
        }}
      >
        <svg
          width={TRACK_W}
          height={TRACK_H}
          viewBox={`0 0 ${TRACK_W} ${TRACK_H}`}
          aria-hidden="true"
          className="pointer-events-none absolute inset-0"
        >
          {/* corona halo, behind the sun. Stroke color is themed via CSS
              (.ns-umbra-corona below) rather than a bare var(--foreground)
              attribute: --foreground flips polarity between themes while the
              occluder's ambient tint always darkens toward black, so a
              foreground-stroked ring is bright-on-dark (high contrast) in
              dark theme but dark-on-mid-gray (low contrast) in light theme.
              The attribute here is just the dark-theme value as a fallback. */}
          <circle
            ref={coronaRef}
            className="ns-umbra-corona"
            cx={INSET}
            cy={TRACK_H / 2}
            r={SUN_R + 3}
            fill="none"
            stroke="var(--foreground)"
            strokeWidth={1}
            opacity={0}
            style={{ filter: "blur(1.5px)" }}
          />
          {/* sun — fixed */}
          <circle cx={INSET} cy={TRACK_H / 2} r={SUN_R} fill="var(--foreground)" />
          {/* occluder — slides across, painted in the track's own resting
              color so it reads as "cutting into" the sun via plain paint
              order rather than a mask. Near full occlusion --umbra-moon
              (set in applyFrame) blends in a themed highlight so the
              eclipsed disc stays legible instead of vanishing into the tint
              it's otherwise identical to. */}
          <circle
            ref={occluderRef}
            cx={value ? INSET : INSET + TRAVEL}
            cy={TRACK_H / 2}
            r={OCCLUDER_R}
            fill="var(--umbra-moon, var(--umbra-tint, var(--border)))"
          />
        </svg>
      </button>

      <span
        aria-hidden="true"
        className={`font-mono text-xs uppercase tracking-wide transition-colors ${
          value ? "text-foreground" : "text-muted"
        }`}
      >
        Dark
      </span>
    </span>
  );
}

const CSS = `
@keyframes ns-umbra-breathe {
  0% { opacity: 1; }
  40% { opacity: 0.35; }
  100% { opacity: 1; }
}
/* Corona stroke: var(--background) in light theme (white, the brightest
   token available, against the mid-gray tint the occluder darkens toward)
   and var(--foreground) in dark theme (unchanged from before — already
   high-contrast against the near-black tint there). */
.ns-umbra-track .ns-umbra-corona { stroke: var(--background); }
.dark .ns-umbra-track .ns-umbra-corona { stroke: var(--foreground); }
/* Same "always the bright token for this theme" trick, exposed as a custom
   property the occluder's near-full-occlusion highlight (--umbra-moon,
   set in applyFrame) mixes into its own fill. */
.ns-umbra-track { --umbra-bright: var(--background); }
.dark .ns-umbra-track { --umbra-bright: var(--foreground); }
@media (prefers-reduced-motion: reduce) {
  button.ns-umbra-track svg circle { transition: none !important; animation: none !important; }
}
`;
Build spec

Build a binary switch (theme toggle or any on/off value) drawn as an eclipse rather than a sliding-pill iOS-style switch. A 56x28 track (a real <button role="switch">, not a decorative div) contains a small SVG with two circles: a fixed bright 'sun' disc (fill var(--foreground), radius ~8, centered 6px from the left edge) and a dark 'occluder' disc (radius ~9, slightly larger than the sun so it can fully cover it) that slides horizontally across the track between the sun's rest position (off) and the far side (on). The track must clip its own overflow (`overflow-hidden` alongside its `rounded-full`) — the occluder's resting position sits close enough to the track's edge that its own curvature (radius ~9) doesn't match the track's cap curvature (radius = half the track height, ~14); left unclipped, the occluder paints straight through the rounded cap and visibly bevels/notches the pill. This is load-bearing, not cosmetic. The occluder's fill is NOT a hardcoded color — set a CSS custom property (e.g. --umbra-tint) on the track button element itself, computed as `color-mix(in srgb, var(--border) X%, #000 Y%)` where Y grows with the occlusion fraction (0 at off, up to ~55% black-mixed at full on), and have the occluder circle's fill read that tint by default so the crescent shape that appears as the sun peeks out from behind it is pure geometry (two overlapping circles), never a sprite or a mask. Because the occluder is concentric with and larger than the sun, at full occlusion the sun's own fill is never visible again — legibility of the eclipsed disc rests entirely on whatever distinguishes the occluder from the track background around it, so an occluder that matches the tint with zero deviation reads as a hole, not a disc, once the corona's contrast runs out (see below). Above the same ~0.72 fraction threshold the corona fades in on, linearly blend the occluder's fill away from pure tint toward a second custom property, e.g. --umbra-moon = `color-mix(in srgb, tint (100-mix)%, --umbra-bright mix%)` where mix ramps 0% to ~22% by fraction=1; below the threshold mix stays 0% and the occluder is byte-identical to the tint (unchanged resting/mid-drag behavior). --umbra-bright is themed the same way the corona stroke is (below): var(--background) in light theme, var(--foreground) in dark theme — always the 'light' token for the current theme, since the tint always darkens toward black in both themes. The track's own background-color is set to that same base tint, so as occlusion increases the whole track visibly darkens in sync with the disc's motion. Near full occlusion (fraction above ~0.72, ramping linearly to 1 at fraction=1), fade in a corona: a third circle behind the sun, stroked 1px, no fill, with a couple of pixels of CSS blur — restrained, not a big glow. The stroke color is themed rather than a single var(--foreground): var(--background) (white) in light theme and var(--foreground) in dark theme, set via a `.dark` CSS override rather than the bare custom property, because --foreground flips polarity between themes while the occluder's ambient tint always darkens toward black — a foreground-stroked ring is high-contrast in dark theme but low-contrast (dark ring on a mid-gray tint) in light theme if left un-themed; even themed, a 1px blurred stroke alone is not enough headroom in light theme, which is why the occluder fill also needs the --umbra-moon blend above. Drive everything from a single 'occlusion fraction' in [0,1]: on discrete changes (click, keyboard) write the occluder's cx attribute and fill, the track's background-color/custom-properties, and the corona's opacity through refs with a short (~220ms) CSS transition; while actively pointer-dragging, write the same three ref properties on every pointermove with transitions disabled so the disc tracks the pointer 1:1 with zero lag, then on release snap to whichever side the fraction ended up closer to (>=0.5 rounds to on) and re-enable the transition for the settle. Distinguish a plain click from a drag by pointer movement: arm on pointerdown without moving anything, and only start continuously updating the fraction once movement exceeds a small threshold (~3px) — a release with no real movement always just flips the current value outright (ignoring where exactly the click landed), while a release after real dragging snaps based on final position; this keeps click and drag as two coherent, non-conflicting gestures. Full switch semantics: role=switch, aria-checked mirrors the boolean value, an aria-label (or accept one from the caller) since there's no visible <label>, and Space/Enter toggles via a keydown handler. A visible 'Light / Dark' label pair in font-mono flanks the track, with whichever word matches the current state rendered at full --foreground and the other at --muted (never conveying the state through color alone — the switch position and aria-checked both carry it too). Hovering the track does two things: it brightens the track's border from --border to --foreground (a plain CSS hover class — this is the one guaranteed-visible hover cue regardless of on/off state, so hover always differs from default even before the drag/click model is used at all), and if the switch is currently ON it also plays the corona 'breathing' once — a single keyframe animation (opacity 1 -> 0.35 -> 1 over ~900ms, not a repeating loop, restarted by clearing and re-setting the animation property) rather than a continuous pulse. prefers-reduced-motion: the occluder disc teleports (transitions disabled entirely) and the ambient tint applies in one discrete step rather than easing; the corona breathe animation and every CSS transition are also suppressed globally for this component via a scoped @media rule. Support both controlled (`checked`/`onCheckedChange`) and uncontrolled (`defaultChecked`) usage, matching the shape of a native form control. Distinct from switch-frost (frost creep, particles, canvas) — this is pure vector geometry, no canvas, no seeded randomness, no accretion effect. Zero dependencies.

Tags
switchtoggletheme-switcherbinarysvgdragaccessibility