# Domain Switch: Dark Mode Toggle for React

> A toggle that expands its new state as a domain across the page. A sphere grows from the switch, everything inside it changes theme or mode, and its edge is a thin seam of stars where the two worlds meet.

Dark Mode Toggle for React: the new theme spreads across the page from the switch. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/domain-switch
- Tags: Toggle, Theme, Transition, Canvas
- npm dependencies: motion, clsx, tailwind-merge
- Inspired by: [Jujutsu Kaisen](https://jujutsukaisen.jp/)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/domain-switch.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/domain-switch
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/domain-switch.json
```

## Usage

The main demo shows typical usage.

### Dark Mode

A landing page's theme toggle. Dark mode opens from the button as a domain and takes the page over, with a seam of stars at its edge; press again to draw it back in.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/domain-switch-demo
```

```tsx title="components/domain-switch-demo.tsx"
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { DomainSwitch, DomainSwitchScope, DomainSwitchToggle } from "@/components/ui/domain-switch";

export function DomainSwitchDemo() {
  const [dark, setDark] = React.useState(false);

  return (
    <div className="w-full overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
      <DomainSwitch checked={dark} onCheckedChange={setDark}>
        <DomainSwitchScope
          className="h-[var(--demo-height,560px)]"
          outside={<Landing dark={false} />}
          inside={<Landing dark />}
        />
      </DomainSwitch>
    </div>
  );
}

function Landing({ dark }: { dark: boolean }) {
  return (
    <div
      className={cn(
        "relative flex h-full flex-col overflow-hidden",
        dark ? "bg-[#09090c] text-zinc-100" : "bg-[#fbfbfa] text-zinc-900",
      )}
    >
      {dark && (
        <div
          aria-hidden
          className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(129,140,248,0.16),transparent)]"
        />
      )}
      <nav className="relative flex items-center gap-6 px-5 py-4 sm:px-8">
        <span className="flex items-center gap-2 text-sm font-semibold tracking-tight">
          <span
            className={cn(
              "grid size-6 place-items-center rounded-md text-[11px] font-bold",
              dark ? "bg-indigo-400 text-[#09090c]" : "bg-zinc-900 text-white",
            )}
          >
            T
          </span>
          Tidal
        </span>
        <div className={cn("hidden gap-5 text-sm sm:flex", dark ? "text-zinc-400" : "text-zinc-500")}>
          <span>Product</span>
          <span>Pricing</span>
          <span>Docs</span>
        </div>
        <div className="ml-auto flex items-center gap-2">
          <DomainSwitchToggle
            aria-label="Dark mode"
            className={cn(
              "size-9 rounded-full border",
              dark
                ? "border-white/10 bg-white/5 text-indigo-200 hover:bg-white/10"
                : "border-zinc-200 bg-white text-zinc-700 shadow-xs hover:bg-zinc-50",
            )}
          >
            {dark ? <MoonIcon /> : <SunIcon />}
          </DomainSwitchToggle>
          <a
            href="#"
            className={cn(
              "hidden rounded-full px-4 py-2 text-sm font-medium sm:block",
              dark ? "bg-zinc-100 text-zinc-900" : "bg-zinc-900 text-white",
            )}
          >
            Sign in
          </a>
        </div>
      </nav>

      <section className="relative mx-auto flex max-w-2xl flex-col items-center px-5 pt-8 text-center sm:pt-12">
        <span
          className={cn(
            "rounded-full border px-3 py-1 text-xs",
            dark ? "border-indigo-400/25 bg-indigo-400/10 text-indigo-200" : "border-zinc-200 bg-white text-zinc-600",
          )}
        >
          New: answers in Slack
        </span>
        <h1 className="mt-5 text-3xl font-semibold tracking-tight text-balance sm:text-5xl/[1.05]">
          Analytics that answer back.
        </h1>
        <p className={cn("mt-4 max-w-md text-sm text-pretty sm:text-base", dark ? "text-zinc-400" : "text-zinc-600")}>
          Ask a question in plain words. Tidal writes the query, checks it and draws the chart.
        </p>
        <div className="mt-6 flex gap-2">
          <a
            href="#"
            className={cn(
              "rounded-full px-5 py-2.5 text-sm font-medium",
              dark ? "bg-indigo-400 text-[#09090c]" : "bg-zinc-900 text-white",
            )}
          >
            Start free
          </a>
          <a
            href="#"
            className={cn(
              "rounded-full border px-5 py-2.5 text-sm font-medium",
              dark ? "border-white/10 text-zinc-200" : "border-zinc-200 bg-white text-zinc-800",
            )}
          >
            Book a demo
          </a>
        </div>
      </section>

      <section className="relative mx-auto mt-10 grid w-full max-w-3xl grid-cols-1 gap-3 px-5 sm:grid-cols-2 sm:px-8">
        <Card dark={dark} title="Live queries" body="Weekly signups, by channel">
          <svg viewBox="0 0 200 48" className="h-12 w-full" aria-hidden>
            <path
              d="M0 40 L20 36 L40 38 L60 28 L80 30 L100 20 L120 24 L140 14 L160 16 L180 8 L200 10"
              fill="none"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
              className={dark ? "stroke-indigo-300" : "stroke-zinc-900"}
            />
          </svg>
        </Card>
        <Card dark={dark} title="Shared boards" body="Three people are looking at Q3">
          <div className="flex h-12 items-center -space-x-2">
            {["bg-amber-300", "bg-sky-300", "bg-rose-300"].map((color) => (
              <span
                key={color}
                className={cn("size-8 rounded-full border-2", color, dark ? "border-[#121217]" : "border-white")}
              />
            ))}
          </div>
        </Card>
      </section>
    </div>
  );
}

function Card({
  dark,
  title,
  body,
  children,
}: {
  dark: boolean;
  title: string;
  body: string;
  children: React.ReactNode;
}) {
  return (
    <div
      className={cn(
        "rounded-2xl border p-4",
        dark ? "border-white/[0.07] bg-[#121217]" : "border-zinc-200 bg-white shadow-xs",
      )}
    >
      {children}
      <p className="mt-3 text-sm font-medium">{title}</p>
      <p className={cn("text-xs", dark ? "text-zinc-500" : "text-zinc-500")}>{body}</p>
    </div>
  );
}

function SunIcon() {
  return (
    <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
      <circle cx="12" cy="12" r="4" />
      <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
    </svg>
  );
}

function MoonIcon() {
  return (
    <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M20.5 14.1A8.5 8.5 0 1 1 9.9 3.5a7 7 0 0 0 10.6 10.6Z" />
    </svg>
  );
}
```

## Examples

Finished sections built with Domain Switch. Each one installs whole, with the component included.

### Focus Mode

A writing app. Focus opens a calm, dark domain in which the sidebar and toolbar, wrapped in `DomainSwitchAside`, dissolve, and the document in `DomainSwitchMain` comes forward. One `Editor` renders both worlds. Esc leaves.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/domain-switch-focus-demo
```

```tsx title="components/domain-switch-focus-demo.tsx"
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import {
  DomainSwitch,
  DomainSwitchAside,
  DomainSwitchMain,
  DomainSwitchScope,
  DomainSwitchToggle,
  useDomainSwitch,
} from "@/components/ui/domain-switch";

const drafts = ["On slow software", "Field notes, March", "The case for less", "Interview: K. Ito"];

export function DomainSwitchFocusDemo() {
  const [focus, setFocus] = React.useState(false);

  React.useEffect(() => {
    if (!focus) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") setFocus(false);
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [focus]);

  return (
    <div className="w-full overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
      <DomainSwitch checked={focus} onCheckedChange={setFocus}>
        <DomainSwitchScope className="h-[var(--demo-height,560px)]" seamClassName="text-stone-950">
          <Editor />
        </DomainSwitchScope>
      </DomainSwitch>
    </div>
  );
}

// One editor for both worlds; it reads which one it's in.
function Editor() {
  const { world } = useDomainSwitch();
  const calm = world === "inside";

  return (
    <div
      className={cn(
        "flex h-full",
        calm ? "bg-[#141311] text-stone-300" : "bg-white text-stone-800",
      )}
    >
      <DomainSwitchAside
        className={cn(
          "hidden w-52 shrink-0 flex-col gap-1 border-r p-3 sm:flex",
          calm ? "border-white/5" : "border-stone-200 bg-stone-50",
        )}
      >
        <p className="px-2 pt-1 pb-2 text-xs font-medium text-stone-400">Drafts</p>
        {drafts.map((draft, i) => (
          <span
            key={draft}
            className={cn(
              "truncate rounded-md px-2 py-1.5 text-sm",
              i === 0 ? (calm ? "bg-white/5 text-stone-200" : "bg-white text-stone-900 shadow-xs") : "text-stone-500",
            )}
          >
            {draft}
          </span>
        ))}
      </DomainSwitchAside>

      <div className="flex min-w-0 flex-1 flex-col">
        <div
          className={cn(
            "flex items-center gap-1 border-b px-3 py-2",
            calm ? "border-transparent" : "border-stone-200",
          )}
        >
          <DomainSwitchAside className="flex items-center gap-1">
            {["B", "I", "U"].map((mark) => (
              <span
                key={mark}
                className={cn(
                  "grid size-8 place-items-center rounded-md text-sm text-stone-500",
                  mark === "B" && "font-bold",
                  mark === "I" && "italic",
                  mark === "U" && "underline",
                )}
              >
                {mark}
              </span>
            ))}
            <span className="mx-1 h-4 w-px bg-stone-200" />
            <span className="grid h-8 place-items-center rounded-md px-2 text-sm text-stone-500">H1</span>
            <span className="hidden h-8 place-items-center rounded-md px-2 text-sm text-stone-500 sm:grid">Quote</span>
          </DomainSwitchAside>
          {calm && <span className="ml-1 text-xs text-stone-500">Esc to leave</span>}
          <DomainSwitchToggle
            className={cn(
              "ml-auto h-8 gap-1.5 rounded-full px-3 text-xs font-medium",
              calm
                ? "bg-amber-200/10 text-amber-100 hover:bg-amber-200/15"
                : "bg-stone-900 text-white hover:bg-stone-800",
            )}
          >
            <FocusIcon />
            {calm ? "Leave focus" : "Focus"}
          </DomainSwitchToggle>
        </div>

        <div className="min-h-0 flex-1 overflow-hidden px-6 pt-8 sm:px-10 sm:pt-12">
          <DomainSwitchMain className="mx-auto max-w-[34rem] origin-top">
            <p className={cn("text-xs", calm ? "text-stone-500" : "text-stone-400")}>Essay · 412 words</p>
            <h2
              className={cn(
                "mt-2 font-serif text-3xl tracking-tight sm:text-4xl",
                calm ? "text-stone-100" : "text-stone-900",
              )}
            >
              On slow software
            </h2>
            <div className="mt-5 space-y-4 font-serif text-[15px]/7 sm:text-base/7">
              <p>
                The best tools I use don&apos;t ask for my attention. They wait. A text editor that opens before
                I&apos;ve finished the thought, a calendar that doesn&apos;t blink.
              </p>
              <p>
                Most software is built to be noticed. Badges, banners, a tour on every release. Each one is
                small, and together they are the reason I can&apos;t finish a paragraph
                <span
                  className={cn(
                    "ml-0.5 inline-block h-[1.1em] w-px translate-y-[3px] animate-pulse",
                    calm ? "bg-amber-200" : "bg-stone-900",
                  )}
                />
              </p>
            </div>
          </DomainSwitchMain>
        </div>
      </div>
    </div>
  );
}

function FocusIcon() {
  return (
    <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
      <circle cx="8" cy="8" r="2.25" />
      <path d="M2 5V3a1 1 0 0 1 1-1h2M11 2h2a1 1 0 0 1 1 1v2M14 11v2a1 1 0 0 1-1 1h-2M5 14H3a1 1 0 0 1-1-1v-2" strokeLinecap="round" />
    </svg>
  );
}
```

### Command Palette

Press ⌘K (or the search icon) and a domain opens from the icon in which the page becomes a command palette. Run a command or press Esc and it collapses back into the icon.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/domain-switch-command-demo
```

```tsx title="components/domain-switch-command-demo.tsx"
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { DomainSwitch, DomainSwitchScope, DomainSwitchToggle } from "@/components/ui/domain-switch";

type Command = { label: string; group: string; hint?: string };

const commands: Command[] = [
  { label: "Redeploy production", group: "Actions", hint: "⇧R" },
  { label: "Roll back to previous build", group: "Actions" },
  { label: "Invite a teammate", group: "Actions", hint: "I" },
  { label: "Open build logs", group: "Go to", hint: "G L" },
  { label: "Environment variables", group: "Go to", hint: "G E" },
  { label: "Domains", group: "Go to", hint: "G D" },
  { label: "Usage and billing", group: "Go to" },
];

const deployments = [
  { branch: "main", message: "Cache edge responses", age: "4m", state: "Ready" },
  { branch: "feat/search", message: "Fuzzy match on titles", age: "38m", state: "Building" },
  { branch: "main", message: "Bump runtime to 22", age: "2h", state: "Ready" },
  { branch: "fix/og", message: "Fallback social card", age: "5h", state: "Ready" },
  { branch: "main", message: "Drop legacy routes", age: "1d", state: "Ready" },
];

export function DomainSwitchCommandDemo() {
  const [open, setOpen] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const [active, setActive] = React.useState(0);
  const [ran, setRan] = React.useState<string | null>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);

  const results = commands.filter((command) => command.label.toLowerCase().includes(query.trim().toLowerCase()));

  const change = (next: boolean) => {
    setOpen(next);
    if (next) {
      setQuery("");
      setActive(0);
    }
  };

  const run = (command: Command) => {
    setRan(command.label);
    change(false);
  };

  // ⌘K / Ctrl+K opens it from anywhere, Esc puts it away.
  React.useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
        event.preventDefault();
        setOpen((was) => {
          if (!was) {
            setQuery("");
            setActive(0);
          }
          return !was;
        });
      } else if (event.key === "Escape") {
        setOpen(false);
      }
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, []);

  React.useEffect(() => {
    if (open) inputRef.current?.focus({ preventScroll: true });
  }, [open]);

  return (
    <div className="w-full overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
      <DomainSwitch checked={open} onCheckedChange={change} duration={0.95}>
        <DomainSwitchScope
          className="h-[var(--demo-height,520px)]"
          seamClassName="text-[#05060d]"
          outside={
            <div className="flex h-full flex-col bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
              <TopBar>
                <DomainSwitchToggle
                  aria-label="Command palette"
                  className="size-8 rounded-lg text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
                >
                  <SearchIcon />
                </DomainSwitchToggle>
                <kbd className="hidden rounded-md border border-zinc-200 px-1.5 py-0.5 font-sans text-[11px] text-zinc-500 sm:block dark:border-zinc-800">
                  ⌘K
                </kbd>
                <span className="ml-auto size-7 rounded-full bg-gradient-to-br from-amber-200 to-rose-300" />
              </TopBar>
              <div className="min-h-0 flex-1 overflow-hidden px-4 py-5 sm:px-6">
                <div className="flex items-baseline justify-between">
                  <h2 className="text-lg font-semibold tracking-tight">Deployments</h2>
                  <p className="truncate pl-4 text-xs text-zinc-500" aria-live="polite">
                    {ran ? `Ran “${ran}”` : "Press ⌘K for commands"}
                  </p>
                </div>
                <ul className="mt-4 divide-y divide-zinc-100 rounded-xl border border-zinc-200 dark:divide-zinc-900 dark:border-zinc-800">
                  {deployments.map((d) => (
                    <li key={d.message} className="flex items-center gap-3 px-4 py-3 text-sm">
                      <span
                        className={cn(
                          "size-2 shrink-0 rounded-full",
                          d.state === "Ready" ? "bg-emerald-500" : "animate-pulse bg-amber-500",
                        )}
                      />
                      <span className="min-w-0 flex-1 truncate">{d.message}</span>
                      <span className="hidden font-mono text-xs text-zinc-500 sm:block">{d.branch}</span>
                      <span className="w-8 text-right text-xs text-zinc-500">{d.age}</span>
                    </li>
                  ))}
                </ul>
              </div>
            </div>
          }
          inside={
            <div className="flex h-full flex-col bg-[#0b0c16] text-zinc-200">
              <TopBar className="border-white/[0.06]">
                <DomainSwitchToggle
                  aria-label="Command palette"
                  className="size-8 rounded-lg bg-indigo-400/15 text-indigo-200 hover:bg-indigo-400/25"
                >
                  <SearchIcon />
                </DomainSwitchToggle>
                <input
                  ref={inputRef}
                  value={query}
                  onChange={(event) => {
                    setQuery(event.target.value);
                    setActive(0);
                  }}
                  onKeyDown={(event) => {
                    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
                      event.preventDefault();
                      const step = event.key === "ArrowDown" ? 1 : -1;
                      setActive((i) => (results.length ? (i + step + results.length) % results.length : 0));
                    } else if (event.key === "Enter" && results[active]) {
                      event.preventDefault();
                      run(results[active]);
                    }
                  }}
                  placeholder="Type a command or search"
                  aria-label="Command"
                  className="min-w-0 flex-1 bg-transparent text-[16px] text-zinc-100 outline-none placeholder:text-zinc-500 sm:text-sm"
                />
                <kbd className="rounded-md border border-white/10 px-1.5 py-0.5 font-sans text-[11px] text-zinc-400">
                  Esc
                </kbd>
              </TopBar>
              <div className="min-h-0 flex-1 overflow-y-auto px-2 py-3 sm:px-4">
                {results.length === 0 && <p className="px-3 py-6 text-sm text-zinc-500">No commands match.</p>}
                {["Actions", "Go to"].map((group) => {
                  const items = results.filter((command) => command.group === group);
                  if (!items.length) return null;
                  return (
                    <div key={group} className="mb-3">
                      <p className="px-3 pb-1 text-[11px] font-medium tracking-wide text-zinc-500 uppercase">{group}</p>
                      {items.map((command) => {
                        const index = results.indexOf(command);
                        return (
                          <button
                            key={command.label}
                            type="button"
                            onPointerMove={() => setActive(index)}
                            onClick={() => run(command)}
                            className={cn(
                              "flex w-full items-center rounded-lg px-3 py-2.5 text-left text-sm",
                              index === active ? "bg-indigo-400/[0.12] text-white" : "text-zinc-300",
                            )}
                          >
                            {command.label}
                            {command.hint && (
                              <span className="ml-auto font-mono text-[11px] text-zinc-500">{command.hint}</span>
                            )}
                          </button>
                        );
                      })}
                    </div>
                  );
                })}
              </div>
            </div>
          }
        />
      </DomainSwitch>
    </div>
  );
}

function TopBar({ className, children }: { className?: string; children: React.ReactNode }) {
  return (
    <div
      className={cn(
        "flex h-14 shrink-0 items-center gap-2 border-b border-zinc-200 px-3 sm:px-4 dark:border-zinc-800",
        className,
      )}
    >
      <span className="mr-2 flex items-center gap-2 text-sm font-semibold tracking-tight">
        <span className="size-4 rotate-45 rounded-[3px] bg-current" />
        <span className="hidden sm:inline">Relay</span>
      </span>
      {children}
    </div>
  );
}

function SearchIcon() {
  return (
    <svg viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden>
      <circle cx="7" cy="7" r="4.5" />
      <path d="m10.5 10.5 3 3" />
    </svg>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `checked` | `boolean` |  | Whether the inside world has taken over. Control it with `onCheckedChange`. |
| `defaultChecked` | `boolean` | `false` | The starting state when it isn't controlled. |
| `onCheckedChange` | `(checked: boolean) => void` |  | Called the moment a toggle is pressed. |
| `onChange` | `(inside: boolean) => void` |  | Called once the domain has finished growing or collapsing, with the world that now covers the scope. A good place to persist a theme or apply it to the rest of the app. Not called when a toggle turns it around mid-flight. |
| `duration` | `number` | `1.1` | Seconds the domain takes to grow across the scope. Collapsing takes a little less. |
| `collapseRatio` | `number` | `0.8` | How long collapsing takes, as a share of `duration`. `1` draws the domain back in as slowly as it grew; lower snaps it shut. |
| `toggle.children` | `React.ReactNode` |  | `DomainSwitchToggle`: the button's content. It's a real switch (`role="switch"`, `aria-checked`, Space and Enter). Put one outside the scope, or one in each world at the same spot, and the domain opens from its center. |
| `toggle.className` | `string` |  | `DomainSwitchToggle`: classes for the button. |
| `scope.inside` | `React.ReactNode` |  | `DomainSwitchScope`: the world the domain brings in. |
| `scope.outside` | `React.ReactNode` |  | `DomainSwitchScope`: the world it takes over. |
| `scope.children` | `React.ReactNode` |  | `DomainSwitchScope`: rendered in whichever world `inside` or `outside` leaves out. One page can serve both by reading `useDomainSwitch().world` or using `DomainSwitchAside` and `DomainSwitchMain`. |
| `scope.seam` | `number` | `10` | `DomainSwitchScope`: width of the seam of stars at the domain's edge, in px. `0` gives a clean edge. |
| `scope.lens` | `number` | `0.05` | `DomainSwitchScope`: how much the inside is magnified while the domain is small. It relaxes to none as the domain grows. |
| `scope.seamClassName` | `string` | `"text-zinc-950"` | `DomainSwitchScope`: classes that set the seam's color (a `text-*` color). Its stars take the opposite shade. |
| `scope.className` | `string` |  | `DomainSwitchScope`: classes for the area the domain covers, which sizes both worlds. It clips them, so give it the rounded corners. |
| `aside.insideClassName` | `string` | `"scale-[0.98] opacity-[0.14] blur-[5px] saturate-50"` | `DomainSwitchAside`: wraps something non-essential. Inside the domain it takes these classes and can't be reached; outside it's left alone. |
| `main.insideClassName` | `string` | `"scale-[1.04]"` | `DomainSwitchMain`: wraps the content that matters. Inside the domain it takes these classes. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/domain-switch.tsx`, keep these, which make it feel and behave right:

- Every frame is written straight to the DOM from one motion value, `progress`: the inside's `clip-path` circle, the lens scale and the canvas seam. Nothing in a frame re-renders React.
- Both worlds are real markup stacked in one grid cell, the inside clipped on top. The world not in charge is `inert` and `aria-hidden`, and focus moves to the same toggle in the other world.
- A toggle mid-flight turns around from the current progress, never from the start; the move scales with the square root of the distance left and never drops under `TIMING.shortest`.
- The sphere opens from the pressed toggle, measured in the unmagnified outside world, and grows past the farthest corner by the seam's width so the seam leaves with it.
- Reduced motion drops the clip, lens and seam and crossfades the inside world's opacity.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/domain-switch.tsx"
"use client";

import * as React from "react";
import { animate, useMotionValue, useReducedMotion, type MotionValue } from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - Every frame is written straight to the DOM from one motion value, `progress`: the inside's
 *   `clip-path` circle, the lens scale and the canvas seam. Nothing in a frame re-renders React.
 * - Both worlds are real markup stacked in one grid cell, the inside clipped on top. The world not
 *   in charge is `inert` and `aria-hidden`, and focus moves to the same toggle in the other world.
 * - A toggle mid-flight turns around from the current progress, never from the start; the move
 *   scales with the square root of the distance left and never drops under `TIMING.shortest`.
 * - The sphere opens from the pressed toggle, measured in the unmagnified outside world, and grows
 *   past the farthest corner by the seam's width so the seam leaves with it.
 * - Reduced motion drops the clip, lens and seam and crossfades the inside world's opacity.
 */
const TIMING = {
  /** The domain growing across the scope, in seconds (the `duration` prop). */
  duration: 1.1,
  /** A collapse, as a share of `duration` (the `collapseRatio` prop). */
  collapseRatio: 0.8,
  /** The curve it grows on: a heavy ease-out, fast start, long settle. */
  grow: [0.2, 0.72, 0.12, 1],
  /** The curve it collapses on: it gathers, then draws in to the toggle. */
  collapse: [0.5, 0, 0.18, 1],
  /** The shortest a move takes, so a reversal near the end still takes this long, in seconds. */
  shortest: 0.35,
  /** The reduced-motion crossfade, in seconds. */
  fade: 0.15,
  /** The stars sliding along the seam, in rad per ms. */
  drift: 0.00004,
  /** A star's twinkle, in rad per ms: the slowest, plus up to `spread` more. */
  twinkle: { base: 0.003, spread: 0.007 },
  /** The toggle's press scale, as Tailwind classes. */
  press: "duration-150 ease-out",
} as const;

/* ─────────────────────────────────────────────────────────
 * DOMAIN SWITCH STORYBOARD
 *
 *  press     the toggle flips and a domain opens from its center
 *  grow      a sphere of the new world swells across the scope:
 *            fast at first, then a long settle out to the
 *            farthest corner. Everything inside it is already the
 *            other world, real markup, so text stays crisp
 *  seam      the edge is a thin band of space, dark with star
 *            points, where the two worlds meet. The inside is
 *            magnified a touch and relaxes as it grows, so the
 *            edge reads as a lens, not a wipe
 *  settle    the sphere passes the last corner, the seam leaves
 *            with it and the old world is put away
 *  collapse  toggle back and the domain draws in to the toggle
 *            on the same seam
 *  reverse   toggle mid-flight and it turns around from where it
 *            is, never from the start
 *  reduced   with reduced motion the worlds crossfade in 150ms
 * ───────────────────────────────────────────────────────── */

const SEAM = {
  stars: 1.7, // px of seam per star
  maxStars: 4800,
  starFade: 48, // stars that fade in as the seam lengthens
} as const;

type World = "inside" | "outside";

type Toggle = { element: HTMLElement; world: World | null };

type Scope = {
  measure: (origin: { x: number; y: number } | null) => void;
  render: (p: number) => void;
  inside: HTMLElement;
  outside: HTMLElement;
  /** The element in the scope that last had focus, kept through the blur that `inert` causes. */
  focused: () => Element | null;
};

/** Keeps the toggles and scopes out of React, so a frame never re-renders anything. */
function createDomain(progress: MotionValue<number>) {
  const toggles = new Set<Toggle>();
  const scopes = new Set<Scope>();
  let pressed: { world: World | null; element: HTMLElement; index: number } | null = null;
  let options = {
    duration: TIMING.duration as number,
    collapseRatio: TIMING.collapseRatio as number,
    reduced: false,
    onChange: undefined as ((inside: boolean) => void) | undefined,
  };
  let target = progress.get();

  // Toggles of one world in document order, so the second toggle outside
  // pairs with the second toggle inside.
  const inWorld = (world: World | null) =>
    [...toggles]
      .filter((toggle) => toggle.world === world)
      .map((toggle) => toggle.element)
      .sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));

  // The sphere opens from the toggle that was pressed, measured in the
  // world that isn't magnified: a toggle outside the scopes, else its twin
  // in the outside world.
  const origin = () => {
    const index = pressed?.index ?? 0;
    const element =
      (pressed?.world === null && pressed.element.isConnected ? pressed.element : null) ??
      inWorld("outside")[index] ??
      inWorld(null)[0] ??
      inWorld("outside")[0] ??
      inWorld("inside")[index] ??
      inWorld("inside")[0];
    if (!element) return null;
    const rect = element.getBoundingClientRect();
    return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
  };

  // The world that was left goes inert, which drops focus on the body. If
  // focus was in it, hand it to the same toggle in the world taking over.
  // A task later: an Enter that closed the domain from a keydown would
  // otherwise go on to click the toggle it lands on, and the app may want
  // to focus something itself.
  const handoff = (inside: boolean) => {
    const active = document.activeElement;
    for (const scope of scopes) {
      const was = active && active !== document.body ? active : scope.focused();
      if (!was || !(inside ? scope.outside : scope.inside).contains(was)) continue;
      const index = pressed?.index ?? 0;
      const next = inWorld(inside ? "inside" : "outside");
      const toggle = next[index] ?? next[0] ?? inWorld(null)[0];
      setTimeout(() => {
        const now = document.activeElement;
        if (now && now !== document.body && !now.closest("[inert]")) return;
        toggle?.focus({ preventScroll: true });
      });
      return;
    }
  };

  return {
    progress,
    setOptions(next: typeof options) {
      options = next;
    },
    reduced: () => options.reduced,
    target: () => target,
    addToggle(element: HTMLElement, world: World | null) {
      const toggle = { element, world };
      toggles.add(toggle);
      return () => void toggles.delete(toggle);
    },
    press(element: HTMLElement, world: World | null) {
      pressed = { element, world, index: Math.max(0, inWorld(world).indexOf(element)) };
    },
    addScope(scope: Scope) {
      scopes.add(scope);
      scope.measure(origin());
      scope.render(progress.get());
      return () => void scopes.delete(scope);
    },
    remeasure(scope: Scope) {
      scope.measure(origin());
      scope.render(progress.get());
    },
    // Runs in the commit that flips `inert`, so the incoming world is shown
    // (clipped to nothing) before anything tries to focus inside it.
    go(inside: boolean) {
      const to = inside ? 1 : 0;
      if (to === target) return;
      target = to;
      const at = origin();
      scopes.forEach((scope) => {
        scope.measure(at);
        scope.render(progress.get());
      });
      handoff(inside);
      const distance = Math.abs(to - progress.get());
      const transition = options.reduced
        ? { duration: TIMING.fade * distance, ease: "linear" as const }
        : {
            duration: Math.max(
              TIMING.shortest,
              options.duration * (inside ? 1 : options.collapseRatio) * Math.sqrt(distance),
            ),
            ease: inside ? TIMING.grow : TIMING.collapse,
          };
      animate(progress, to, transition).then(() => {
        if (target === to) options.onChange?.(inside);
      });
    },
  };
}

type Domain = ReturnType<typeof createDomain>;

type DomainSwitchContextValue = {
  checked: boolean;
  setChecked: (checked: boolean) => void;
  domain: Domain;
};

const DomainSwitchContext = React.createContext<DomainSwitchContextValue | null>(null);
const WorldContext = React.createContext<World | null>(null);

function useDomainSwitchContext(part: string) {
  const context = React.useContext(DomainSwitchContext);
  if (!context) throw new Error(`<${part}> must be used inside <DomainSwitch>.`);
  return context;
}

/** The switch's state, and which world the caller is rendered in (`null` outside a scope). */
export function useDomainSwitch() {
  const { checked, setChecked } = useDomainSwitchContext("useDomainSwitch");
  const world = React.useContext(WorldContext);
  return { checked, setChecked, world };
}

export interface DomainSwitchProps {
  /** Whether the inside world has taken over. Control it with `onCheckedChange`. */
  checked?: boolean;
  defaultChecked?: boolean;
  /** Called the moment the toggle is pressed. */
  onCheckedChange?: (checked: boolean) => void;
  /** Called once the domain has finished growing or collapsing, with the world that now covers the scope. Not called when a toggle reverses it mid-flight. */
  onChange?: (inside: boolean) => void;
  /** Seconds the domain takes to grow across the scope. Collapsing is a little quicker. */
  duration?: number;
  /** How long collapsing takes, as a share of `duration`. `1` draws the domain in as slowly as it grew. */
  collapseRatio?: number;
  children?: React.ReactNode;
}

export function DomainSwitch({
  checked: checkedProp,
  defaultChecked = false,
  onCheckedChange,
  onChange,
  duration = TIMING.duration,
  collapseRatio = TIMING.collapseRatio,
  children,
}: DomainSwitchProps) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultChecked);
  const checked = checkedProp ?? uncontrolled;
  const progress = useMotionValue(checked ? 1 : 0);
  const [domain] = React.useState(() => createDomain(progress));
  const reduced = useReducedMotion() ?? false;

  React.useEffect(() => {
    domain.setOptions({ duration, collapseRatio, reduced, onChange });
  }, [domain, duration, collapseRatio, reduced, onChange]);

  React.useLayoutEffect(() => {
    domain.go(checked);
  }, [domain, checked]);

  const setChecked = React.useCallback(
    (next: boolean) => {
      if (checkedProp === undefined) setUncontrolled(next);
      onCheckedChange?.(next);
    },
    [checkedProp, onCheckedChange],
  );

  const value = React.useMemo(() => ({ checked, setChecked, domain }), [checked, setChecked, domain]);

  return <DomainSwitchContext.Provider value={value}>{children}</DomainSwitchContext.Provider>;
}

export interface DomainSwitchToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  children?: React.ReactNode;
}

/**
 * The switch itself. Put it outside the scope, or inside the worlds: one in
 * each world at the same spot, and the domain opens from it.
 */
export function DomainSwitchToggle({ className, children, onClick, ...props }: DomainSwitchToggleProps) {
  const { checked, setChecked, domain } = useDomainSwitchContext("DomainSwitchToggle");
  const world = React.useContext(WorldContext);
  const ref = React.useRef<HTMLButtonElement>(null);

  React.useLayoutEffect(() => {
    if (!ref.current) return;
    return domain.addToggle(ref.current, world);
  }, [domain, world]);

  return (
    <button
      ref={ref}
      type="button"
      role="switch"
      aria-checked={checked}
      data-state={checked ? "on" : "off"}
      onClick={(event) => {
        onClick?.(event);
        if (event.defaultPrevented) return;
        domain.press(event.currentTarget, world);
        setChecked(!checked);
      }}
      className={cn(
        `relative inline-flex cursor-pointer items-center justify-center transition-transform ${TIMING.press} outline-none select-none focus-visible:ring-2 focus-visible:ring-current/40 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent active:scale-[0.94]`,
        className,
      )}
      {...props}
    >
      {children}
    </button>
  );
}

export interface DomainSwitchScopeProps extends React.HTMLAttributes<HTMLDivElement> {
  /** The world the domain brings in. */
  inside?: React.ReactNode;
  /** The world it takes over. */
  outside?: React.ReactNode;
  /** Rendered in whichever world `inside` or `outside` leaves out, e.g. one page that reads `useDomainSwitch().world` or uses `DomainSwitchAside`. */
  children?: React.ReactNode;
  /** Width of the seam of stars at the edge of the domain, in px. `0` gives a clean edge. */
  seam?: number;
  /** How much the inside is magnified while the domain is small, e.g. `0.05` for 5%. It relaxes to none as it grows. */
  lens?: number;
  /** Classes that set the seam's color (a `text-*` color). Its stars take the opposite shade. */
  seamClassName?: string;
}

/**
 * The area the domain takes over. Both worlds are real markup stacked in
 * one cell; the inside one is clipped to the sphere on top.
 */
export function DomainSwitchScope({
  inside,
  outside,
  children,
  seam = 10,
  lens = 0.05,
  className,
  seamClassName,
  ...props
}: DomainSwitchScopeProps) {
  const { checked, domain } = useDomainSwitchContext("DomainSwitchScope");
  const rootRef = React.useRef<HTMLDivElement>(null);
  const outsideRef = React.useRef<HTMLDivElement>(null);
  const insideRef = React.useRef<HTMLDivElement>(null);
  const lensRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  // The first paint, before the effect takes over: only the world in charge shows.
  const [initial] = React.useState(checked);

  React.useLayoutEffect(() => {
    const root = rootRef.current;
    const outsideEl = outsideRef.current;
    const insideEl = insideRef.current;
    const lensEl = lensRef.current;
    const canvas = canvasRef.current;
    if (!root || !outsideEl || !insideEl || !lensEl || !canvas) return;
    const context = canvas.getContext("2d");

    let width = 0;
    let height = 0;
    let dpr = 1;
    let cx = 0;
    let cy = 0;
    let radius = 0;
    let band: [number, number, number] = [9, 9, 11];
    let star = "#fff";
    let drawn = false;
    let focused: Element | null = null;

    const scope: Scope = {
      inside: insideEl,
      outside: outsideEl,
      focused: () => focused,
      measure(origin) {
        const box = canvas.getBoundingClientRect();
        width = box.width;
        height = box.height;
        dpr = Math.min(window.devicePixelRatio || 1, 2);
        const w = Math.round(width * dpr);
        const h = Math.round(height * dpr);
        if (canvas.width !== w || canvas.height !== h) {
          canvas.width = w;
          canvas.height = h;
        }
        cx = origin ? origin.x - box.left : width / 2;
        cy = origin ? origin.y - box.top : height / 2;
        // Out to the farthest corner, plus the seam, so the seam leaves too.
        radius =
          Math.max(
            Math.hypot(cx, cy),
            Math.hypot(width - cx, cy),
            Math.hypot(cx, height - cy),
            Math.hypot(width - cx, height - cy),
          ) +
          seam +
          2;
        band = toRgb(getComputedStyle(canvas).color);
        star = luminance(band) < 0.5 ? "#f4f6ff" : "#0b0c10";
      },
      render(p) {
        const reduced = domain.reduced();
        const target = domain.target();
        const settledIn = p >= 1 && target === 1;
        const settledOut = p <= 0 && target === 0;
        insideEl.style.visibility = settledOut ? "hidden" : "";
        outsideEl.style.visibility = settledIn ? "hidden" : "";

        if (reduced || settledIn || settledOut) {
          insideEl.style.clipPath = "";
          insideEl.style.opacity = reduced && !settledIn ? String(p) : "";
          lensEl.style.transform = "";
          if (drawn && context) {
            context.clearRect(0, 0, canvas.width, canvas.height);
            drawn = false;
          }
          return;
        }

        const r = p * radius;
        insideEl.style.opacity = "";
        insideEl.style.clipPath = `circle(${r}px at ${cx}px ${cy}px)`;
        lensEl.style.transformOrigin = `${cx}px ${cy}px`;
        lensEl.style.transform = lens ? `scale(${1 + lens * (1 - p) * (1 - p)})` : "";
        if (context && seam > 0) {
          drawSeam(context, { width, height, dpr, cx, cy, r, seam, band, star, t: performance.now() });
          drawn = true;
        }
      },
    };

    const onFocusIn = (event: FocusEvent) => {
      focused = event.target as Element;
    };
    const onFocusOut = (event: FocusEvent) => {
      // Focus going somewhere else on the page, not just dropped.
      if (event.relatedTarget && !root.contains(event.relatedTarget as Node)) focused = null;
    };
    root.addEventListener("focusin", onFocusIn);
    root.addEventListener("focusout", onFocusOut);
    const remove = domain.addScope(scope);
    const unsubscribe = domain.progress.on("change", scope.render);
    const observer = new ResizeObserver(() => domain.remeasure(scope));
    observer.observe(root);
    return () => {
      root.removeEventListener("focusin", onFocusIn);
      root.removeEventListener("focusout", onFocusOut);
      remove();
      unsubscribe();
      observer.disconnect();
    };
  }, [domain, seam, lens]);

  const outsideWorld = outside ?? children;
  const insideWorld = inside ?? children;

  return (
    <div
      ref={rootRef}
      className={cn(
        "relative isolate grid grid-cols-[minmax(0,1fr)] grid-rows-[minmax(0,1fr)] overflow-hidden",
        className,
      )}
      {...props}
    >
      <div
        ref={outsideRef}
        inert={checked}
        aria-hidden={checked || undefined}
        className="relative [grid-area:1/1]"
        style={initial ? { visibility: "hidden" } : undefined}
      >
        <WorldContext.Provider value="outside">{outsideWorld}</WorldContext.Provider>
      </div>
      <div
        ref={insideRef}
        inert={!checked}
        aria-hidden={!checked || undefined}
        className="relative [grid-area:1/1]"
        style={initial ? undefined : { visibility: "hidden" }}
      >
        <div ref={lensRef} className="h-full w-full">
          <WorldContext.Provider value="inside">{insideWorld}</WorldContext.Provider>
        </div>
      </div>
      <canvas
        ref={canvasRef}
        aria-hidden
        className={cn("pointer-events-none absolute inset-0 z-10 size-full text-zinc-950", seamClassName)}
      />
    </div>
  );
}

export interface DomainSwitchAsideProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Classes it takes inside the domain. Replaces the default dim and blur. */
  insideClassName?: string;
}

/** Something non-essential: inside the domain it dims and blurs away, and can't be reached. */
export function DomainSwitchAside({ className, insideClassName, ...props }: DomainSwitchAsideProps) {
  const world = React.useContext(WorldContext);
  const isInside = world === "inside";
  return (
    <div
      inert={isInside || undefined}
      className={cn(
        className,
        isInside && (insideClassName ?? "scale-[0.98] opacity-[0.14] blur-[5px] saturate-50"),
      )}
      {...props}
    />
  );
}

export interface DomainSwitchMainProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Classes it takes inside the domain. Replaces the default scale. */
  insideClassName?: string;
}

/** The content that matters: inside the domain it comes forward a touch. */
export function DomainSwitchMain({ className, insideClassName, ...props }: DomainSwitchMainProps) {
  const world = React.useContext(WorldContext);
  return (
    <div
      className={cn(className, world === "inside" && (insideClassName ?? "scale-[1.04]"))}
      {...props}
    />
  );
}

/* ── the seam ─────────────────────────────────────────────── */

type SeamFrame = {
  width: number;
  height: number;
  dpr: number;
  cx: number;
  cy: number;
  r: number;
  seam: number;
  band: [number, number, number];
  star: string;
  t: number;
};

const TAU = Math.PI * 2;
const PHI = 0.6180339887498949;

// A stable random number per star, so a star keeps its place and size.
function hash(k: number, salt: number) {
  const x = Math.sin(k * 127.1 + salt * 311.7) * 43758.5453;
  return x - Math.floor(x);
}

function drawSeam(ctx: CanvasRenderingContext2D, f: SeamFrame) {
  const { width, height, cx, cy, r, seam, t } = f;
  ctx.setTransform(f.dpr, 0, 0, f.dpr, 0, 0);
  ctx.clearRect(0, 0, width, height);
  if (r <= 0.5) return;
  // A sphere the size of a toggle is all seam; let it come in.
  const grown = Math.min(1, r / (seam * 4));
  const [cr, cg, cb] = f.band;
  const rgba = (a: number) => `rgba(${cr},${cg},${cb},${a * grown})`;

  // The band: a strip of space across the edge, with a soft shadow
  // reaching in so the inside sits a little deeper.
  const inner = Math.max(0, r - seam * 3.2);
  const outer = r + seam * 0.75;
  const at = (x: number) => Math.min(1, Math.max(0, (x - inner) / (outer - inner)));
  const gradient = ctx.createRadialGradient(cx, cy, inner, cx, cy, outer);
  gradient.addColorStop(0, rgba(0));
  gradient.addColorStop(at(r - seam * 1.2), rgba(0.12));
  gradient.addColorStop(at(r - seam * 0.4), rgba(0.85));
  gradient.addColorStop(at(r + seam * 0.3), rgba(0.85));
  gradient.addColorStop(1, rgba(0));
  ctx.fillStyle = gradient;
  ctx.beginPath();
  ctx.arc(cx, cy, outer, 0, TAU);
  ctx.arc(cx, cy, inner, 0, TAU, true);
  ctx.fill();

  // Stars on a low-discrepancy sequence of angles: any first n of them are
  // spread evenly, so the seam fills in as it lengthens and none jump.
  const count = Math.min(SEAM.maxStars, (TAU * r) / SEAM.stars);
  const last = Math.min(SEAM.maxStars, Math.ceil(count + SEAM.starFade));
  const spin = t * TIMING.drift;
  ctx.fillStyle = f.star;
  for (let k = 0; k < last; k++) {
    const u = hash(k, 1);
    const v = hash(k, 2);
    const angle = ((k * PHI) % 1) * TAU + spin;
    // Across the strip, thickest at the edge itself.
    const d = r + (u - 0.5) * (0.4 + 0.6 * hash(k, 4)) * seam * 1.3;
    const x = cx + Math.cos(angle) * d;
    const y = cy + Math.sin(angle) * d;
    if (x < -2 || y < -2 || x > width + 2 || y > height + 2) continue;
    const twinkle = 0.55 + 0.45 * Math.sin(t * (TIMING.twinkle.base + TIMING.twinkle.spread * v) + u * TAU);
    let alpha = (0.45 + 0.55 * hash(k, 3)) * twinkle * grown;
    if (k > count) alpha *= 1 - (k - count) / SEAM.starFade;
    if (alpha <= 0.02) continue;
    const size = 0.7 + v * v * 1.3;
    ctx.globalAlpha = alpha;
    ctx.fillRect(x - size / 2, y - size / 2, size, size);
    // One in thirty is a glint with a faint halo.
    if (v > 0.967) {
      ctx.globalAlpha = alpha * 0.22;
      ctx.fillRect(x - 1.75, y - 1.75, 3.5, 3.5);
    }
  }
  ctx.globalAlpha = 1;
}

// Canvas gradients need rgb. Computed colors can be oklch, so let the
// canvas resolve it.
let probe: CanvasRenderingContext2D | null = null;
function toRgb(color: string): [number, number, number] {
  probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
  if (!probe) return [9, 9, 11];
  probe.clearRect(0, 0, 1, 1);
  probe.fillStyle = "#000";
  probe.fillStyle = color;
  probe.fillRect(0, 0, 1, 1);
  const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
  return [r, g, b];
}

function luminance([r, g, b]: [number, number, number]) {
  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
```
