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.
<DomainSwitch />The demo follows as you drag.
Ask a question in plain words. Tidal writes the query, checks it and draws the chart.
Live queries
Weekly signups, by channel
Shared boards
Three people are looking at Q3
"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>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Domain Switch: 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. This demo, 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. 1. Install it: npx shadcn@latest add @keyframeui/domain-switch-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/domain-switch-demo.json instead.) It adds the demo, components/domain-switch-demo.tsx, and the component, components/ui/domain-switch.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <DomainSwitch> on its own. 3. These props set its feel. Change them before you change the file: - duration (default 1.1): Seconds the domain takes to grow across the scope. Collapsing takes a little less. - collapseRatio (default 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. - seam on <DomainSwitchScope> (default 10): Width of the seam of stars at the domain's edge, in px. `0` gives a clean edge. - lens on <DomainSwitchScope> (default 0.05): How much the inside is magnified while the domain is small. It relaxes to none as the domain grows. 4. Don't change, in components/ui/domain-switch.tsx: - 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 is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/domain-switch.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/domain-switch.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/domain-switchInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
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; }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
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.
Drafts
On slow softwareField notes, MarchThe case for lessInterview: K. ItoEssay · 412 words
The best tools I use don't ask for my attention. They wait. A text editor that opens before I've finished the thought, a calendar that doesn't blink.
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't finish a paragraph
"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't ask for my attention. They wait. A text editor that opens before
I've finished the thought, a calendar that doesn'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'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>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Domain Switch: 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. This demo, 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. 1. Install it: npx shadcn@latest add @keyframeui/domain-switch-focus-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/domain-switch-focus-demo.json instead.) It adds the demo, components/domain-switch-focus-demo.tsx, and the component, components/ui/domain-switch.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <DomainSwitch> on its own. 3. These props set its feel. Change them before you change the file: - duration (default 1.1): Seconds the domain takes to grow across the scope. Collapsing takes a little less. - collapseRatio (default 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. - seam on <DomainSwitchScope> (default 10): Width of the seam of stars at the domain's edge, in px. `0` gives a clean edge. - lens on <DomainSwitchScope> (default 0.05): How much the inside is magnified while the domain is small. It relaxes to none as the domain grows. 4. Don't change, in components/ui/domain-switch.tsx: - 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 is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/domain-switch.md
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.
Press ⌘K for commands
- Cache edge responsesmain4m
- Fuzzy match on titlesfeat/search38m
- Bump runtime to 22main2h
- Fallback social cardfix/og5h
- Drop legacy routesmain1d
"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>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Domain Switch: 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. This demo, 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. 1. Install it: npx shadcn@latest add @keyframeui/domain-switch-command-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/domain-switch-command-demo.json instead.) It adds the demo, components/domain-switch-command-demo.tsx, and the component, components/ui/domain-switch.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <DomainSwitch> on its own. 3. These props set its feel. Change them before you change the file: - duration (default 1.1): Seconds the domain takes to grow across the scope. Collapsing takes a little less. - collapseRatio (default 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. - seam on <DomainSwitchScope> (default 10): Width of the seam of stars at the domain's edge, in px. `0` gives a clean edge. - lens on <DomainSwitchScope> (default 0.05): How much the inside is magnified while the domain is small. It relaxes to none as the domain grows. 4. Don't change, in components/ui/domain-switch.tsx: - 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 is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/domain-switch.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | None | 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 | None | Called the moment a toggle is pressed. |
onChange | (inside: boolean) => void | None | 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 | None | 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 | None | DomainSwitchToggle: classes for the button. |
scope.inside | React.ReactNode | None | DomainSwitchScope: the world the domain brings in. |
scope.outside | React.ReactNode | None | DomainSwitchScope: the world it takes over. |
scope.children | React.ReactNode | None | 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 | None | 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. |
checkedType
booleanWhether the inside world has taken over. Control it with
onCheckedChange.defaultCheckedType
booleanDefaultfalseThe starting state when it isn't controlled.
onCheckedChangeType
(checked: boolean) => voidCalled the moment a toggle is pressed.
onChangeType
(inside: boolean) => voidCalled 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.
durationType
numberDefault1.1Seconds the domain takes to grow across the scope. Collapsing takes a little less.
collapseRatioType
numberDefault0.8How long collapsing takes, as a share of
duration.1draws the domain back in as slowly as it grew; lower snaps it shut.toggle.childrenType
React.ReactNodeDomainSwitchToggle: 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.classNameType
stringDomainSwitchToggle: classes for the button.scope.insideType
React.ReactNodeDomainSwitchScope: the world the domain brings in.scope.outsideType
React.ReactNodeDomainSwitchScope: the world it takes over.scope.childrenType
React.ReactNodeDomainSwitchScope: rendered in whichever worldinsideoroutsideleaves out. One page can serve both by readinguseDomainSwitch().worldor usingDomainSwitchAsideandDomainSwitchMain.scope.seamType
numberDefault10DomainSwitchScope: width of the seam of stars at the domain's edge, in px.0gives a clean edge.scope.lensType
numberDefault0.05DomainSwitchScope: how much the inside is magnified while the domain is small. It relaxes to none as the domain grows.scope.seamClassNameType
stringDefault"text-zinc-950"DomainSwitchScope: classes that set the seam's color (atext-*color). Its stars take the opposite shade.scope.classNameType
stringDomainSwitchScope: classes for the area the domain covers, which sizes both worlds. It clips them, so give it the rounded corners.aside.insideClassNameType
stringDefault"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.insideClassNameType
stringDefault"scale-[1.04]"DomainSwitchMain: wraps the content that matters. Inside the domain it takes these classes.