Motion

Liquid selector

Grab near an edge to stretch the surface. Release to let it settle.

ReactTypeScriptWhite / Black

Preview

Build selected · Drag from an edge or use arrow keys

Usage

Liquid selector usage
"use client";
import { useState } from "react";
import LiquidSelector3D from "@/components/3d-liquid-selector";

export default function Example() {
  const [value, setValue] = useState("build");
  return (
    <LiquidSelector3D
      label="Workflow"
      value={value}
      onValueChange={setValue}
      options={[
        { value: "design", label: "Design" },
        { value: "build", label: "Build" },
        { value: "ship", label: "Ship" },
      ]}
    />
  );
}

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-merge
Import
import LiquidSelector3D from "@/components/3d-liquid-selector";

API reference

PropType / defaultDescription
labelstringAccessible name for the group.
options{ value: string, label: string }[]Items to display.
valuestringSelected value in controlled mode.
defaultValuestringInitial value in uncontrolled mode; defaults to the first available option.
onValueChange(value: string) => voidCalled when selection changes.
colorTheme"white" | "black"Default: "black"Surface finish.
disabledbooleanDefault: falseDisables 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; }
}