Motion
Liquid toggle
A two-position version with the same fluid motion.
ReactTypeScriptWhite / Black
Preview
Power off
Usage
Liquid toggle usage
"use client";
import LiquidSelector3D from "@/components/3d-liquid-selector";
export default function Example() {
return (
<LiquidSelector3D
label="Power"
options={[
{ value: "off", label: "Off" },
{ value: "on", label: "On" },
]}
/>
);
}Installation
Use these components in a Next.js project with Tailwind CSS. Install the dependencies, copy the source files below into your project, and include the shared styles in your global stylesheet. Keep the @/ import alias pointing to your project root.
Terminal
npm install motion lucide-react clsx tailwind-mergeImport
import LiquidSelector3D from "@/components/3d-liquid-selector";API reference
| Prop | Type / default | Description |
|---|---|---|
| label | string | Accessible name for the group. |
| options | { value: string, label: string }[] | Items to display. |
| value | string | Selected value in controlled mode. |
| defaultValue | string | Initial value in uncontrolled mode; defaults to the first available option. |
| onValueChange | (value: string) => void | Called when selection changes. |
| colorTheme | "white" | "black"Default: "black" | Surface finish. |
| disabled | booleanDefault: false | Disables the entire selector. |
Accessibility & interaction
Drag with a mouse or touch pointer. A 4 px movement threshold separates dragging from clicking. Arrow keys, Home, End, Space, and Enter work without dragging. Reduced-motion settings disable the simulation.
Source files
Current source used by the preview, including its local dependencies.
components/3d-liquid-selector.tsx
components/3d-liquid-selector.tsx
"use client";
import { useEffect, useId, useRef, useState, type PointerEvent } from "react";
import { animate, motion, useMotionValue, useReducedMotion } from "motion/react";
import { createSoftBody, softBodyPath, stepSoftBody, type Point } from "@/lib/soft-body";
import { cn } from "@/lib/utils";
type LiquidSelector3DProps = {
label: string;
options: { value: string; label: string }[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
colorTheme?: "white" | "black";
disabled?: boolean;
className?: string;
};
export default function LiquidSelector3D({ label, options, value, defaultValue, onValueChange, colorTheme = "black", disabled = false, className }: LiquidSelector3DProps) {
const filterId = `liquid-${useId().replace(/:/g, "")}`;
const trackRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{ pointer: number; start: number; offset: number; moved: boolean; clickIndex: number; grip: Point } | null>(null);
const [internal, setInternal] = useState(defaultValue ?? options[0]?.value ?? "");
const [layout, setLayout] = useState({ width: 0, stride: 0, buttonWidth: 0, origin: 0 });
const { width, stride: cell, buttonWidth, origin } = layout;
const [dragging, setDragging] = useState(false);
const selectedIndex = Math.max(0, options.findIndex((option) => option.value === (value ?? internal)));
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
const maxX = origin + cell * (options.length - 1);
const reducedMotion = useReducedMotion();
const target = useMotionValue(0);
const surface = useMotionValue("");
const snapPosition = useMotionValue(0);
const snapRef = useRef<() => void>(() => {});
const gripRef = useRef<Point | null>(null);
const wakeRef = useRef<() => void>(() => {});
useEffect(() => {
if (!cell) return;
const body = createSoftBody(buttonWidth, target.get());
surface.set(softBodyPath(body.particles));
let frame = 0;
let previousTime = 0;
let accumulator = 0;
let visible = false;
let settledFrames = 0;
let snapping = false;
let stopSnap = () => {};
const restCenterX = body.rest.reduce((sum, p) => sum + p.x, 0) / body.rest.length;
const tick = (time: number) => {
frame = 0;
if (!visible || document.hidden) return;
if (reducedMotion) {
body.particles.forEach((p, index) => Object.assign(p, { x: body.rest[index].x + target.get(), y: body.rest[index].y, vx: 0, vy: 0 }));
surface.set(softBodyPath(body.particles));
return;
}
accumulator += Math.min((time - (previousTime || time - 16.67)) / 1000, 0.04);
previousTime = time;
let motion = 0;
while (accumulator >= 1 / 120) {
motion = stepSoftBody(body, target.get(), gripRef.current);
accumulator -= 1 / 120;
}
if (gripRef.current || snapping) {
const centerX = body.particles.reduce((sum, p) => sum + p.x, 0) / body.particles.length;
const averageVX = body.particles.reduce((sum, p) => sum + p.vx, 0) / body.particles.length;
const desiredPosition = gripRef.current ? target.get() : snapPosition.get();
const shift = desiredPosition + restCenterX - centerX;
body.particles.forEach((p) => { p.x += shift; p.vx -= averageVX; });
}
surface.set(softBodyPath(body.particles));
settledFrames = motion < 0.3 && !gripRef.current ? settledFrames + 1 : 0;
if (settledFrames < 12) frame = requestAnimationFrame(tick);
};
const wake = () => {
settledFrames = 0;
if (!frame && visible && !document.hidden) {
previousTime = 0;
frame = requestAnimationFrame(tick);
}
};
const snap = () => {
stopSnap();
if (gripRef.current || reducedMotion) { snapping = false; wake(); return; }
const current = body.particles.reduce((sum, p) => sum + p.x, 0) / body.particles.length - restCenterX;
snapPosition.set(current);
snapping = true;
const animation = animate(snapPosition, target.get(), {
duration: 0.24,
ease: [0.25, 0.1, 0.25, 1],
onComplete: () => {
const centerX = body.particles.reduce((sum, p) => sum + p.x, 0) / body.particles.length;
const averageVX = body.particles.reduce((sum, p) => sum + p.vx, 0) / body.particles.length;
body.particles.forEach((p) => { p.x += target.get() + restCenterX - centerX; p.vx -= averageVX; });
snapping = false;
wake();
},
});
stopSnap = () => animation.stop();
wake();
};
wakeRef.current = wake;
snapRef.current = snap;
const unsubscribe = target.on("change", () => { if (gripRef.current) { stopSnap(); snapping = false; wake(); } else snap(); });
const observer = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) wake();
else { cancelAnimationFrame(frame); frame = 0; }
});
if (trackRef.current) observer.observe(trackRef.current);
document.addEventListener("visibilitychange", wake);
return () => {
cancelAnimationFrame(frame);
stopSnap();
observer.disconnect();
unsubscribe();
document.removeEventListener("visibilitychange", wake);
wakeRef.current = () => {};
snapRef.current = () => {};
};
}, [cell, buttonWidth, reducedMotion, target, surface, snapPosition]);
useEffect(() => {
const element = trackRef.current;
if (!element) return;
const measure = () => {
const buttons = element.querySelectorAll<HTMLButtonElement>('[role="radio"]');
const first = buttons[0];
if (!first) return;
const next = {
width: element.clientWidth,
stride: buttons[1] ? buttons[1].offsetLeft - first.offsetLeft : first.offsetWidth,
buttonWidth: first.offsetWidth,
origin: first.offsetLeft,
};
setLayout(previous => Object.keys(next).every(key => previous[key as keyof typeof next] === next[key as keyof typeof next]) ? previous : next);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!dragging) target.set(origin + selectedIndex * cell);
}, [selectedIndex, cell, origin, dragging, target]);
const clampX = (x: number) => Math.max(origin, Math.min(maxX, x));
const choose = (index: number) => {
const option = options[index];
if (!option || disabled) return;
setInternal(option.value);
target.set(origin + index * cell);
onValueChange?.(option.value);
};
const finish = (event: PointerEvent<HTMLDivElement>, cancelled = false) => {
if (dragRef.current?.pointer !== event.pointerId) return;
const drag = dragRef.current;
const releaseX = clampX(drag.offset + event.clientX - drag.start);
dragRef.current = null;
gripRef.current = null;
wakeRef.current();
setDragging(false);
setPreviewIndex(null);
if (cancelled) target.set(origin + selectedIndex * cell);
else {
const next = drag.moved ? Math.round((releaseX - origin) / Math.max(1, cell)) : drag.clickIndex;
choose(next);
snapRef.current();
event.currentTarget.querySelectorAll<HTMLButtonElement>('button')[next]?.focus({ preventScroll: true });
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
return (
<div className={cn("inline-flex max-w-full", className)}>
<div
ref={trackRef}
role="radiogroup"
aria-label={label}
aria-disabled={disabled || undefined}
data-dragging={dragging}
data-disabled={disabled}
className="glass-tray liquid-track"
onPointerDown={(event) => {
if (disabled || event.button !== 0 || !cell) return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
const localX = event.clientX - rect.left - event.currentTarget.clientLeft;
const currentX = target.get();
const clickIndex = Math.max(0, Math.min(options.length - 1, Math.round((localX - origin - buttonWidth / 2) / cell)));
dragRef.current = {
pointer: event.pointerId,
start: event.clientX,
offset: currentX,
moved: false,
clickIndex,
grip: { x: Math.max(0, Math.min(buttonWidth, localX - currentX)), y: event.clientY - rect.top },
};
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.querySelector<HTMLButtonElement>('[aria-checked="true"]')?.focus({ preventScroll: true });
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag || drag.pointer !== event.pointerId) return;
if (!drag.moved) {
if (Math.abs(event.clientX - drag.start) < 4) return;
drag.moved = true;
gripRef.current = drag.grip;
setDragging(true);
wakeRef.current();
}
const x = clampX(drag.offset + event.clientX - drag.start);
target.set(x);
setPreviewIndex(Math.round((x - origin) / Math.max(1, cell)));
}}
onPointerUp={(event) => finish(event)}
onPointerCancel={(event) => finish(event, true)}
onLostPointerCapture={(event) => finish(event, true)}
>
<svg aria-hidden="true" className="liquid-surface" width="100%" height="50" style={{ opacity: width ? 1 : 0 }}>
<defs>
<linearGradient id={`${filterId}-material`} x1="0" y1="0" x2="0.15" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stopColor={colorTheme === "black" ? "#52525b" : "#ffffff"} />
<stop offset="0.22" stopColor={colorTheme === "black" ? "#3f3f46" : "#fafafa"} />
<stop offset="0.62" stopColor={colorTheme === "black" ? "#27272a" : "#f9fafb"} />
<stop offset="1" stopColor={colorTheme === "black" ? "#18181b" : "#e5e7eb"} />
</linearGradient>
<radialGradient id={`${filterId}-glow`} cx="0.5" cy="0.08" r="0.65" gradientTransform="translate(0 0.04) scale(1 0.7)">
<stop offset="0" stopColor="#ffffff" stopOpacity={colorTheme === "black" ? "0.18" : "0.7"} />
<stop offset="1" stopColor="#ffffff" stopOpacity="0" />
</radialGradient>
<filter id={filterId} x="-35%" y="-100%" width="170%" height="300%" colorInterpolationFilters="sRGB">
<feOffset in="SourceAlpha" dy="0.8" result="lower" />
<feComposite in="SourceAlpha" in2="lower" operator="out" result="rim" />
<feFlood floodColor="#ffffff" floodOpacity="0.4" result="rimColor" />
<feComposite in="rimColor" in2="rim" operator="in" result="highlight" />
<feOffset in="SourceAlpha" dy="-1" result="upper" />
<feComposite in="SourceAlpha" in2="upper" operator="out" result="bottom" />
<feFlood floodColor="#000000" floodOpacity="0.16" result="bottomColor" />
<feComposite in="bottomColor" in2="bottom" operator="in" result="shade" />
<feMerge result="litSurface"><feMergeNode in="SourceGraphic" /><feMergeNode in="highlight" /><feMergeNode in="shade" /></feMerge>
<feDropShadow in="litSurface" dx="0" dy="3" stdDeviation="3" floodColor="#18181b" floodOpacity="0.17" />
</filter>
</defs>
<g filter={`url(#${filterId})`}>
<motion.path d={surface} fill={`url(#${filterId}-material)`} />
<motion.path d={surface} fill={`url(#${filterId}-glow)`} />
</g>
</svg>
{options.map((option, index) => (
<button key={option.value} type="button" role="radio" aria-checked={selectedIndex === index} tabIndex={selectedIndex === index ? 0 : -1} disabled={disabled} data-active={(previewIndex ?? selectedIndex) === index} data-theme={colorTheme} className="liquid-option" onClick={(event) => { if (event.detail === 0 && !dragRef.current) choose(index); }} onKeyDown={(event) => {
if (!["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : (index + (["ArrowRight", "ArrowDown"].includes(event.key) ? 1 : -1) + options.length) % options.length;
choose(next);
trackRef.current?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
}}><span style={{ display: "inline-block", transform: "translateY(1px)" }}>{option.label}</span></button>
))}
</div>
</div>
);
}
lib/soft-body.ts
lib/soft-body.ts
export type Point = { x: number; y: number };
type Particle = Point & { vx: number; vy: number };
export type SoftBody = {
rest: Point[];
particles: Particle[];
restArea: number;
links: { a: number; b: number; length: number; stiffness: number }[];
};
export function polygonArea(points: Point[]) {
return points.reduce((area, point, index) => {
const next = points[(index + 1) % points.length];
return area + point.x * next.y - next.x * point.y;
}, 0) / 2;
}
export function createSoftBody(width: number, offset = 0): SoftBody {
const radius = Math.min(19, Math.max(4, width / 2));
const straight = Math.max(0, width - radius * 2);
const perimeter = straight * 2 + Math.PI * radius * 2;
const rest = Array.from({ length: 48 }, (_, index) => {
let distance = index / 48 * perimeter;
if (distance < straight) return { x: radius + distance, y: 24 - radius };
distance -= straight;
if (distance < Math.PI * radius) {
const angle = distance / radius - Math.PI / 2;
return { x: radius + straight + Math.cos(angle) * radius, y: 24 + Math.sin(angle) * radius };
}
distance -= Math.PI * radius;
if (distance < straight) return { x: radius + straight - distance, y: 24 + radius };
distance -= straight;
const angle = distance / radius + Math.PI / 2;
return { x: radius + Math.cos(angle) * radius, y: 24 + Math.sin(angle) * radius };
});
const links = [1, 2, 3].flatMap((gap) => rest.map((point, a) => {
const b = (a + gap) % rest.length;
return { a, b, length: Math.hypot(rest[b].x - point.x, rest[b].y - point.y), stiffness: gap === 1 ? 0.8 : 0.32 };
}));
return { rest, particles: rest.map((point) => ({ x: point.x + offset, y: point.y, vx: 0, vy: 0 })), restArea: polygonArea(rest), links };
}
/** Fixed-step position-based soft body. Edge constraints model surface tension;
* an area constraint preserves volume in 2D. A localized spring is the grab. */
export function stepSoftBody(body: SoftBody, target: number, grab: Point | null, dt = 1 / 120) {
const { particles, rest } = body;
const old = particles.map(({ x, y }) => ({ x, y }));
const center = particles.reduce((sum, p) => ({ x: sum.x + p.x / particles.length, y: sum.y + p.y / particles.length }), { x: 0, y: 0 });
const restCenter = rest.reduce((sum, p) => ({ x: sum.x + p.x / rest.length, y: sum.y + p.y / rest.length }), { x: 0, y: 0 });
// Broad grab forces limit local distortion; a separate release spring snaps promptly.
const damping = Math.exp(-(grab ? 36 : 42) * dt);
particles.forEach((p, index) => {
const r = rest[index];
const distance = grab ? Math.hypot(r.x - grab.x, r.y - grab.y) : 0;
const grip = grab ? 800 + 1400 * Math.exp(-distance * distance / (2 * 44 * 44)) : 1100;
const shapeX = center.x + r.x - restCenter.x;
const shapeY = center.y + r.y - restCenter.y;
p.vx = (p.vx + ((r.x + target - p.x) * grip + (shapeX - p.x) * 240) * dt) * damping;
p.vy = (p.vy + ((r.y - p.y) * grip + (shapeY - p.y) * 240) * dt) * damping;
p.x += p.vx * dt;
p.y += p.vy * dt;
});
for (let iteration = 0; iteration < 8; iteration++) {
for (const link of body.links) {
const a = particles[link.a], b = particles[link.b];
const dx = b.x - a.x, dy = b.y - a.y;
const length = Math.hypot(dx, dy) || 1;
const correction = (length - link.length) / length * link.stiffness * 0.5;
a.x += dx * correction; a.y += dy * correction;
b.x -= dx * correction; b.y -= dy * correction;
}
const gradients = particles.map((_, index) => {
const previous = particles[(index + particles.length - 1) % particles.length];
const next = particles[(index + 1) % particles.length];
return { x: (next.y - previous.y) / 2, y: (previous.x - next.x) / 2 };
});
const denominator = gradients.reduce((sum, g) => sum + g.x * g.x + g.y * g.y, 0);
const pressure = (polygonArea(particles) - body.restArea) / Math.max(1, denominator) * 0.9;
particles.forEach((p, index) => {
p.x -= gradients[index].x * pressure;
p.y = Math.max(3, Math.min(45, p.y - gradients[index].y * pressure));
});
}
let motion = 0;
particles.forEach((p, index) => {
p.vx = (p.x - old[index].x) / dt;
p.vy = (p.y - old[index].y) / dt;
motion = Math.max(motion, Math.hypot(p.vx, p.vy), Math.hypot(p.x - rest[index].x - target, p.y - rest[index].y) * 10);
});
return motion;
}
export function softBodyPath(points: Point[]) {
if (!points.length) return "";
let path = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
for (let index = 0; index < points.length; index++) {
const a = points[(index - 1 + points.length) % points.length];
const b = points[index];
const c = points[(index + 1) % points.length];
const d = points[(index + 2) % points.length];
path += ` C ${(b.x + (c.x - a.x) / 6).toFixed(2)} ${(b.y + (c.y - a.y) / 6).toFixed(2)}, ${(c.x - (d.x - b.x) / 6).toFixed(2)} ${(c.y - (d.y - b.y) / 6).toFixed(2)}, ${c.x.toFixed(2)} ${c.y.toFixed(2)}`;
}
return `${path} Z`;
}
lib/utils.ts
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Shared styles
globals.css
/* Glossy controls: shared material, lighting, and pressed depth. */
.glass-control {
--glass-face: linear-gradient(135deg, #fff, #f9fafb 48%, #e5e7eb);
--glass-ink: #18181b;
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 0.5rem;
border: 1px solid rgb(255 255 255 / 0.55);
background: var(--glass-face);
color: var(--glass-ink);
box-shadow: inset 0 1px 1px rgb(255 255 255 / 0.85), inset 0 -2px 4px rgb(0 0 0 / 0.07), 0 3px 5px rgb(24 24 27 / 0.1), 0 8px 16px -8px rgb(24 24 27 / 0.18);
cursor: pointer;
transition: transform 180ms ease, box-shadow 180ms ease, color 180ms ease, background 180ms ease;
}
.glass-control[data-theme="black"] {
--glass-face: linear-gradient(135deg, #52525b, #27272a 55%, #18181b);
--glass-ink: #fff;
border-color: rgb(63 63 70 / 0.65);
box-shadow: inset 0 1px 1px rgb(255 255 255 / 0.2), inset 0 -2px 4px rgb(0 0 0 / 0.25), 0 3px 5px rgb(24 24 27 / 0.15), 0 8px 16px -8px rgb(24 24 27 / 0.25);
}
.glass-control::before {
content: "";
position: absolute;
z-index: -1;
inset: 3px 5px 45%;
border-radius: inherit;
background: linear-gradient(rgb(255 255 255 / 0.28), transparent);
filter: blur(2px);
pointer-events: none;
}
.glass-control:hover:not(:disabled) { transform: translateY(-1px); }
.glass-control:active:not(:disabled) { transform: translateY(1px) scale(0.97); }
.glass-control:focus-visible,
.glass-native-input:focus-visible + span {
outline: 2px solid #527349;
outline-offset: 4px;
}
.glass-control:disabled { opacity: 0.4; cursor: not-allowed; }
.glass-icon { width: 44px; height: 44px; border-radius: 50%; }
.glass-icon svg { width: 18px; height: 18px; }
.glass-icon[data-selected="true"] { color: #166534; }
.glass-icon[data-selected="true"][data-theme="black"] { color: #86efac; }
.glass-tray {
display: inline-flex;
gap: 4px;
padding: 5px;
border: 1px solid #dededb;
border-radius: 999px;
background: #e9e9e6;
box-shadow: inset 0 2px 4px rgb(24 24 27 / 0.08), 0 1px 0 rgb(255 255 255 / 0.9);
}
.glass-tab, .glass-segment { min-height: 38px; padding: 0 18px; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; }
.glass-tab[aria-selected="false"],
.glass-segment-label input:not(:checked) + .glass-segment {
background: transparent;
border-color: transparent;
color: #52525b;
box-shadow: none;
}
.glass-tab[aria-selected="false"]::before,
.glass-segment-label input:not(:checked) + .glass-segment::before { opacity: 0; }
.glass-check-label, .glass-segment-label { position: relative; display: inline-flex; align-items: center; gap: 12px; cursor: pointer; font-size: 14px; color: #3f3f46; }
.glass-native-input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.glass-check { width: 26px; height: 26px; border-radius: 8px; }
.glass-check svg { opacity: 0; transform: scale(0.65); transition: opacity 150ms ease, transform 150ms ease; }
.glass-native-input:checked + .glass-check svg { opacity: 1; transform: scale(1); }
.glass-native-input:not(:checked) + .glass-check { --glass-face: linear-gradient(135deg, #fff, #f4f4f5); --glass-ink: #18181b; border-color: #d4d4d8; box-shadow: inset 0 1px 3px rgb(24 24 27 / 0.09), 0 1px 0 white; }
.glass-native-input:disabled ~ span { opacity: 0.4; cursor: not-allowed; }
.glass-check-label:has(input:disabled), .glass-segment-label:has(input:disabled) { cursor: not-allowed; }
@property --switch-progress {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.glass-switch {
--switch-progress: 0;
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-flex;
align-items: center;
width: 52px;
height: 30px;
padding: 3px;
border: 1px solid #d4d4d8;
border-radius: 999px;
background: #dededb;
box-shadow: inset 0 2px 5px rgb(24 24 27 / 0.16), 0 1px 0 white;
transition: --switch-progress 180ms ease;
}
.glass-switch::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: linear-gradient(135deg, #52525b, #18181b);
clip-path: inset(0 calc(100% - var(--switch-progress) * 100%) 0 0);
pointer-events: none;
}
.glass-switch[data-theme="white"]::after { background: linear-gradient(135deg, #a7c2a6, #527349); }
.glass-switch .glass-switch-thumb {
position: relative;
z-index: 1;
width: 24px;
height: 24px;
border-radius: 50%;
}
.glass-native-input:checked + .glass-switch { --switch-progress: 1; }
@media (prefers-reduced-motion: reduce) {
.glass-control, .glass-control::before, .glass-check svg,
.glass-switch, .glass-switch-thumb { transition: none; }
}
.liquid-track {
display: inline-grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
position: relative;
width: max-content;
max-width: 100%;
touch-action: pan-y;
user-select: none;
cursor: grab;
}
.liquid-track[data-dragging="true"] { cursor: grabbing; }
.liquid-track[data-disabled="true"] { opacity: 0.4; cursor: not-allowed; }
.liquid-surface { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
.liquid-option { display: flex; align-items: center; justify-content: center; position: relative; z-index: 1; flex: 1 0 0; min-width: 0; min-height: 38px; padding: 0 18px; white-space: nowrap; border-radius: 999px; color: #52525b; font-size: 12px; font-weight: 500; cursor: inherit; transition: color 150ms ease; }
.liquid-option[data-active="true"] { color: #18181b; }
.liquid-option[data-active="true"][data-theme="black"] { color: white; }
.liquid-option:focus-visible { outline: 2px solid #527349; outline-offset: 4px; }
@media (prefers-reduced-motion: reduce) { .liquid-option { transition: none; } }
.glass-tab {
position: relative;
isolation: isolate;
flex-shrink: 0;
border: 0;
background: transparent;
color: #52525b;
cursor: pointer;
transition: color 180ms ease;
}
.glass-tab[aria-selected="true"] { color: #18181b; }
.glass-tab[aria-selected="true"][data-theme="black"] { color: #fff; }
.glass-tab:disabled { opacity: 0.4; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) { .glass-tab { transition: none; } }
.glass-tabs-tray { position: relative; isolation: isolate; }
.glass-tabs-tray > .glass-tab { z-index: 1; isolation: auto; }
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
.glass-tabs-tray > .glass-tab {
transition: color 140ms ease, scale 140ms ease-out;
}
.glass-tabs-tray > .glass-tab:hover:not(:disabled) { scale: 1.04; }
}