Liquid Glass Docs

Liquid Glass is a copy-paste React component library for translucent, frosted-glass interfaces inspired by iOS 26. It includes 70 components built with Tailwind CSS v4, Framer Motion, and a shared glass system.

Each component is a self-contained .tsx file. Copy the source into your own project and customize it.

Quick start

  1. Add the CSS variables and glass utilities to your stylesheet.
  2. Wrap your app in ThemeProvider.
  3. Copy any component file from src/components/liquid-glass/ into your project.

Open the interactive docs

Components

GlassSheen

Category: Theme & Glass Primitives

Reusable specular sheen overlay for glass surfaces.

Props

PropTypeRequiredDescription
className string No Additional Tailwind CSS classes.
opacity number No -

Usage

<GlassSheen />

Source

import { cn } from "../../utils/cn";
import { useGlassSurface } from "./useGlassSurface";

interface GlassSheenProps {
  className?: string;
  opacity?: number;
}

export function GlassSheen({ className, opacity = 0.12 }: GlassSheenProps) {
  const { style, className: baseClass } = useGlassSurface({ variant: "sheen", opacity });
  return <div className={cn(baseClass, className)} style={style} />;
}

Open in interactive docs

GlassTopHighlight

Category: Theme & Glass Primitives

Reusable top highlight line used across glass components.

Props

PropTypeRequiredDescription
className string No Additional Tailwind CSS classes.
opacity number No -

Usage

<GlassTopHighlight />

Source

import { cn } from "../../utils/cn";
import { useGlassSurface } from "./useGlassSurface";

interface GlassTopHighlightProps {
  className?: string;
  opacity?: number;
}

export function GlassTopHighlight({ className, opacity = 0.2 }: GlassTopHighlightProps) {
  const { style, className: baseClass } = useGlassSurface({ variant: "highlight", opacity });
  return <div className={cn(baseClass, className)} style={style} />;
}

Open in interactive docs

LiquidGlassAccordion

Category: Data Display

Collapsible accordion panels.

Props

PropTypeRequiredDescription
items AccordionItem[] Yes -
className string No Additional Tailwind CSS classes.
allowMultiple boolean No -
defaultOpen string[] No -

Usage

<LiquidGlassAccordion />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";

interface AccordionItem {
  id: string;
  title: ReactNode;
  content: ReactNode;
  icon?: ReactNode;
}

interface LiquidGlassAccordionProps {
  items: AccordionItem[];
  className?: string;
  allowMultiple?: boolean;
  defaultOpen?: string[];
}

export function LiquidGlassAccordion({
  items,
  className,
  allowMultiple = false,
  defaultOpen = [],
}: LiquidGlassAccordionProps) {
  const [openItems, setOpenItems] = useState<Set<string>>(new Set(defaultOpen));

  const toggle = (id: string) => {
    setOpenItems((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        if (!allowMultiple) next.clear();
        next.add(id);
      }
      return next;
    });
  };

  return (
    <div className={cn("space-y-2", className)}>
      {items.map((item) => {
        const isOpen = openItems.has(item.id);
        return (
          <motion.div
            key={item.id}
            className={cn(
              "overflow-hidden rounded-2xl",
              "glass-blur-sm glass-surface glass-border",
              isOpen && "glass-highlight"
            )}
          >
            <motion.button
              whileTap={{ scale: 0.99 }}
              onClick={() => toggle(item.id)}
              className={cn(
                "flex w-full items-center gap-3 px-5 py-4 text-left transition-colors",
                isOpen ? "text-[var(--lg-text)]" : "text-[var(--lg-text-secondary)] hover:text-[var(--lg-text)]"
              )}
            >
              {item.icon && (
                <span className="flex-shrink-0 text-[var(--lg-text-muted)]">{item.icon}</span>
              )}
              <span className="flex-1 text-sm font-medium">{item.title}</span>
              <motion.div
                animate={{ rotate: isOpen ? 180 : 0 }}
                transition={{ duration: 0.2 }}
                className="flex-shrink-0 text-[var(--lg-text-muted)]"
              >
                <ChevronDown size={16} />
              </motion.div>
            </motion.button>
            <AnimatePresence initial={false}>
              {isOpen && (
                <motion.div
                  initial={{ height: 0, opacity: 0 }}
                  animate={{ height: "auto", opacity: 1 }}
                  exit={{ height: 0, opacity: 0 }}
                  transition={{ duration: 0.3, ease: "easeInOut" }}
                >
                  <div className="px-5 pb-4 text-sm text-[var(--lg-text-secondary)] leading-relaxed">
                    {item.content}
                  </div>
                </motion.div>
              )}
            </AnimatePresence>
          </motion.div>
        );
      })}
    </div>
  );
}

Open in interactive docs

LiquidGlassAlert

Category: Feedback & Status

Theme-aware alert banners with status variants.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
variant "info" | "success" | "warning" | "error" No Visual variant to use.
title string No Title text.
onClose () => void No Callback when the component requests to close.
icon ReactNode No Icon element to display.

Usage

<LiquidGlassAlert />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { X, AlertTriangle, CheckCircle, Info, AlertCircle } from "lucide-react";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { useLiquidTapScale } from "./useLiquidMotion";

interface LiquidGlassAlertProps {
  children: ReactNode;
  className?: string;
  variant?: "info" | "success" | "warning" | "error";
  title?: string;
  onClose?: () => void;
  icon?: ReactNode;
}

const variantStyles = {
  info: {
    border: "border-liquid-blue/20",
    bg: "bg-liquid-blue/8",
    icon: <Info size={18} className="text-liquid-blue" />,
    glow: "bg-liquid-blue/10",
  },
  success: {
    border: "border-liquid-emerald/20",
    bg: "bg-liquid-emerald/8",
    icon: <CheckCircle size={18} className="text-liquid-emerald" />,
    glow: "bg-liquid-emerald/10",
  },
  warning: {
    border: "border-liquid-amber/20",
    bg: "bg-liquid-amber/8",
    icon: <AlertTriangle size={18} className="text-liquid-amber" />,
    glow: "bg-liquid-amber/10",
  },
  error: {
    border: "border-liquid-rose/20",
    bg: "bg-liquid-rose/8",
    icon: <AlertCircle size={18} className="text-liquid-rose" />,
    glow: "bg-liquid-rose/10",
  },
};

export function LiquidGlassAlert({
  children,
  className,
  variant = "info",
  title,
  onClose,
  icon,
}: LiquidGlassAlertProps) {
  const tapScale = useLiquidTapScale();
  const v = variantStyles[variant];

  return (
    <motion.div
      initial={{ opacity: 0, y: -10 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: -10 }}
      className={cn(
        "relative flex gap-3 rounded-2xl p-4",
        "glass-blur-sm border",
        v.border,
        v.bg,
        className
      )}
    >
      {/* Glow */}
      <div className={cn("absolute -top-4 -left-4 h-16 w-16 rounded-full blur-2xl", v.glow)} />
      
      <div className="relative flex-shrink-0 mt-0.5">{icon || v.icon}</div>
      <div className="relative flex-1 min-w-0">
        {title && (
          <h4 className="text-sm font-semibold text-[var(--lg-text)] mb-1">{title}</h4>
        )}
        <div className="text-sm text-[var(--lg-text-secondary)] leading-relaxed">{children}</div>
      </div>
      {onClose && (
        <motion.button
          whileHover={{ scale: 1.1 }}
          whileTap={{ scale: tapScale }}
          onClick={onClose}
          className="flex-shrink-0 text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors"
        >
          <X size={16} />
        </motion.button>
      )}
      {/* Top highlight */}
      <GlassTopHighlight className="inset-x-0 top-0" opacity={0.15} />
    </motion.div>
  );
}

Open in interactive docs

LiquidGlassAvatar

Category: Media & Content

Avatar with fallback, status, and ring.

Props

PropTypeRequiredDescription
src string No -
alt string No -
fallback string | ReactNode No -
className string No Additional Tailwind CSS classes.
size "xs" | "sm" | "md" | "lg" | "xl" No Size preset.
status "online" | "offline" | "away" | "busy" No -
ring boolean No -
ringColor string No -

Usage

<LiquidGlassAvatar />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import type { ReactNode } from "react";

interface LiquidGlassAvatarProps {
  src?: string;
  alt?: string;
  fallback?: string | ReactNode;
  className?: string;
  size?: "xs" | "sm" | "md" | "lg" | "xl";
  status?: "online" | "offline" | "away" | "busy";
  ring?: boolean;
  ringColor?: string;
}

const sizeStyles = {
  xs: "w-6 h-6 text-[10px]",
  sm: "w-8 h-8 text-xs",
  md: "w-10 h-10 text-sm",
  lg: "w-14 h-14 text-base",
  xl: "w-20 h-20 text-lg",
};

const statusColors = {
  online: "bg-liquid-emerald",
  offline: "bg-[var(--lg-text-muted)]",
  away: "bg-liquid-amber",
  busy: "bg-liquid-rose",
};

const statusSizes = {
  xs: "w-1.5 h-1.5",
  sm: "w-2 h-2",
  md: "w-2.5 h-2.5",
  lg: "w-3 h-3",
  xl: "w-4 h-4",
};

export function LiquidGlassAvatar({
  src,
  alt,
  fallback,
  className,
  size = "md",
  status,
  ring = false,
  ringColor = "rgba(255,255,255,0.15)",
}: LiquidGlassAvatarProps) {
  const hasImage = src && src.length > 0;
  const fallbackText =
    typeof fallback === "string"
      ? fallback
          .split(" ")
          .map((w) => w[0])
          .join("")
          .slice(0, 2)
          .toUpperCase()
      : fallback;

  return (
    <motion.div
      whileHover={{ scale: 1.05 }}
      className={cn("relative inline-flex", className)}
    >
      <div
        className={cn(
          "relative flex items-center justify-center rounded-full overflow-hidden",
          "bg-gradient-to-br from-white/10 to-white/5",
          sizeStyles[size],
        )}
        style={ring ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined}
      >
        {hasImage ? (
          <img
            src={src}
            alt={alt || "Avatar"}
            className="h-full w-full object-cover"
          />
        ) : (
          <span className="font-semibold text-[var(--lg-text-secondary)]">{fallbackText}</span>
        )}
        
        {/* Glass overlay */}
        <div className="absolute inset-0 rounded-full ring-1 ring-inset ring-white/10" />
      </div>
      
      {status && (
        <span
          className={cn(
            "absolute bottom-0 right-0 rounded-full ring-2 ring-[#0a0a0f]",
            statusColors[status],
            statusSizes[size]
          )}
        />
      )}
    </motion.div>
  );
}

Open in interactive docs

LiquidGlassBadge

Category: Feedback & Status

Small status badge with dot and color variants.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
variant "default" | "primary" | "success" | "warning" | "danger" | "info" No Visual variant to use.
size "sm" | "md" No Size preset.
dot boolean No -

Usage

<LiquidGlassBadge />

Source

import { cn } from "../../utils/cn";
import type { ReactNode } from "react";

interface LiquidGlassBadgeProps {
  children: ReactNode;
  className?: string;
  variant?: "default" | "primary" | "success" | "warning" | "danger" | "info";
  size?: "sm" | "md";
  dot?: boolean;
}

const variantStyles = {
  default: "bg-[var(--lg-border)] text-[var(--lg-text-secondary)] border-[var(--lg-border-subtle)]",
  primary: "bg-liquid-blue/15 text-liquid-blue border-liquid-blue/20",
  success: "bg-liquid-emerald/15 text-liquid-emerald border-liquid-emerald/20",
  warning: "bg-liquid-amber/15 text-liquid-amber border-liquid-amber/20",
  danger: "bg-liquid-rose/15 text-liquid-rose border-liquid-rose/20",
  info: "bg-liquid-cyan/15 text-liquid-cyan border-liquid-cyan/20",
};

const sizeStyles = {
  sm: "px-2 py-0.5 text-xs rounded-lg gap-1",
  md: "px-2.5 py-1 text-xs rounded-xl gap-1.5",
};

const dotColors = {
  default: "bg-[var(--lg-text-muted)]",
  primary: "bg-liquid-blue",
  success: "bg-liquid-emerald",
  warning: "bg-liquid-amber",
  danger: "bg-liquid-rose",
  info: "bg-liquid-cyan",
};

export function LiquidGlassBadge({
  children,
  className,
  variant = "default",
  size = "md",
  dot = false,
}: LiquidGlassBadgeProps) {
  return (
    <span
      className={cn(
        "inline-flex items-center font-medium",
        "glass-blur-sm border",
        variantStyles[variant],
        sizeStyles[size],
        className
      )}
    >
      {dot && (
        <span
          className={cn(
            "h-1.5 w-1.5 rounded-full",
            dotColors[variant]
          )}
        />
      )}
      {children}
    </span>
  );
}

Open in interactive docs

LiquidGlassBreadcrumb

Category: Navigation

Breadcrumb navigation with separators.

Props

PropTypeRequiredDescription
items BreadcrumbItem[] Yes -
className string No Additional Tailwind CSS classes.
separator React.ReactNode No -

Usage

<LiquidGlassBreadcrumb />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { ChevronRight, Home } from "lucide-react";

interface BreadcrumbItem {
  label: string;
  href?: string;
  icon?: React.ReactNode;
}

interface LiquidGlassBreadcrumbProps {
  items: BreadcrumbItem[];
  className?: string;
  separator?: React.ReactNode;
}

export function LiquidGlassBreadcrumb({
  items,
  className,
  separator = <ChevronRight size={14} className="text-[var(--lg-text-muted)]" />,
}: LiquidGlassBreadcrumbProps) {
  return (
    <nav
      className={cn(
        "inline-flex items-center gap-1.5 px-4 py-2 rounded-xl",
        "glass-blur-sm glass-surface glass-border",
        className
      )}
    >
      {items.map((item, i) => {
        const isLast = i === items.length - 1;
        return (
          <div key={i} className="flex items-center gap-1.5">
            {i > 0 && separator}
            {item.href ? (
              <motion.a
                href={item.href}
                whileHover={{ scale: 1.03 }}
                className="flex items-center gap-1.5 text-sm text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors"
              >
                {item.icon || (i === 0 && <Home size={14} />)}
                {item.label}
              </motion.a>
            ) : (
              <span
                className={cn(
                  "flex items-center gap-1.5 text-sm",
                  isLast ? "text-[var(--lg-text-secondary)] font-medium" : "text-[var(--lg-text-muted)]"
                )}
              >
                {item.icon || (i === 0 && <Home size={14} />)}
                {item.label}
              </span>
            )}
          </div>
        );
      })}
    </nav>
  );
}

Open in interactive docs

LiquidGlassButton

Category: Buttons & FABs

Primary button with liquid ripple and glass surface styles.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
variant "primary" | "secondary" | "ghost" | "danger" | "success" No Visual variant to use.
size "sm" | "md" | "lg" No Size preset.
icon ReactNode No Icon element to display.
iconPosition "left" | "right" No -
fullWidth boolean No -
loading boolean No -
disabled boolean No Whether the component is disabled.
onClick () => void No Callback when the element is clicked.
type "button" | "submit" | "reset" No Input type or visual variant.
fluid boolean No -

Usage

<LiquidGlassButton />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useCallback, type ReactNode, type MouseEvent } from "react";
import { useLiquidPress } from "./useLiquidPress";
import { LiquidGlassPressSplash } from "./LiquidGlassPressSplash";
import { useLiquidTapScale } from "./useLiquidMotion";

interface RippleData {
  id: number;
  x: number;
  y: number;
}

interface LiquidGlassButtonProps {
  children: ReactNode;
  className?: string;
  variant?: "primary" | "secondary" | "ghost" | "danger" | "success";
  size?: "sm" | "md" | "lg";
  icon?: ReactNode;
  iconPosition?: "left" | "right";
  fullWidth?: boolean;
  loading?: boolean;
  disabled?: boolean;
  onClick?: () => void;
  type?: "button" | "submit" | "reset";
  fluid?: boolean;
}

const variantStyles = {
  primary:
    "glass-blur glass-surface-strong glass-border glass-highlight text-[var(--lg-text)]",
  secondary:
    "glass-blur-sm glass-surface glass-border-subtle glass-inner-glow text-[var(--lg-text)]",
  ghost:
    "bg-transparent hover:glass-surface text-[var(--lg-text-secondary)] hover:text-[var(--lg-text)]",
  danger:
    "glass-blur glass-surface glass-border glass-highlight text-liquid-rose",
  success:
    "glass-blur glass-surface glass-border glass-highlight text-liquid-emerald",
};

const sizeStyles = {
  sm: "px-4 py-2 text-sm rounded-xl gap-1.5",
  md: "px-5 py-2.5 text-sm rounded-2xl gap-2",
  lg: "px-7 py-3.5 text-base rounded-2xl gap-2.5",
};

export function LiquidGlassButton({
  children,
  className,
  variant = "primary",
  size = "md",
  icon,
  iconPosition = "left",
  fullWidth = false,
  loading = false,
  disabled,
  onClick,
  type = "button",
  fluid = true,
}: LiquidGlassButtonProps) {
  const tapScale = useLiquidTapScale();
  const [ripples, setRipples] = useState<RippleData[]>([]);
  const { state: press, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel } =
    useLiquidPress<HTMLButtonElement>(disabled || loading);

  const createRipple = useCallback(
    (e: MouseEvent<HTMLButtonElement>) => {
      if (disabled || loading) return;
      const rect = e.currentTarget.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      const id = Date.now();
      setRipples((prev) => [...prev, { id, x, y }]);
      setTimeout(() => {
        setRipples((prev) => prev.filter((r) => r.id !== id));
      }, 900);
    },
    [disabled, loading]
  );

  return (
    <motion.button
      whileHover={{ scale: disabled || loading ? 1 : 1.03 }}
      whileTap={{ scale: disabled || loading ? 1 : tapScale }}
      disabled={disabled || loading}
      onPointerDown={onPointerDown}
      onPointerUp={onPointerUp}
      onPointerLeave={onPointerLeave}
      onPointerCancel={onPointerCancel}
      onClick={(e: any) => {
        createRipple(e);
        onClick?.();
      }}
      type={type}
      className={cn(
        "relative inline-flex items-center justify-center font-medium overflow-hidden isolate",
        "transition-all duration-200",
        variantStyles[variant],
        sizeStyles[size],
        fullWidth && "w-full",
        (disabled || loading) && "opacity-50 cursor-not-allowed",
        className
      )}
    >
      {/* Top highlight */}
      <div className="pointer-events-none absolute inset-x-2 top-0 h-px glass-top-highlight rounded-full z-20" />

      {/* Liquid-glass press splash */}
      {fluid && <LiquidGlassPressSplash press={press} size={140} />}

      {/* Fluid compression shadow — mimics glass being pressed */}
      {fluid && (
        <motion.div
          animate={{
            opacity: press.isPressed ? 0.5 : 0,
          }}
          transition={{ duration: 0.15 }}
          className="pointer-events-none absolute inset-0 rounded-[inherit] z-0"
          style={{
            boxShadow:
              "inset 0 6px 18px rgba(0,0,0,0.28), inset 0 -2px 6px rgba(255,255,255,0.12)",
          }}
        />
      )}

      {/* Subtle refraction blob */}
      <div className="pointer-events-none absolute -top-8 -right-8 h-16 w-16 rounded-full glass-reflection blur-2xl z-0" />

      {/* Liquid ripple effects */}
      <AnimatePresence>
        {ripples.map((ripple) => (
          <motion.span
            key={ripple.id}
            initial={{
              scale: 0,
              opacity: 0.55,
              borderRadius: "45% 55% 50% 50% / 55% 45% 50% 50%",
            }}
            animate={{
              scale: 2.8,
              opacity: 0,
              borderRadius: "50%",
            }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.85, ease: [0.25, 0.46, 0.45, 0.94] }}
            className="absolute pointer-events-none z-0"
            style={{
              left: ripple.x,
              top: ripple.y,
              width: 60,
              height: 60,
              marginLeft: -30,
              marginTop: -30,
              background:
                "radial-gradient(circle, var(--lg-ripple) 0%, transparent 65%)",
            }}
          />
        ))}
      </AnimatePresence>

      {loading && (
        <motion.div
          animate={{ rotate: 360 }}
          transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
          className="mr-2 h-4 w-4 rounded-full border-2 border-current/30 border-t-current relative z-10"
        />
      )}
      {icon && iconPosition === "left" && !loading && (
        <span className="flex-shrink-0 relative z-10">{icon}</span>
      )}
      <span className="relative z-10">{children}</span>
      {icon && iconPosition === "right" && (
        <span className="flex-shrink-0 relative z-10">{icon}</span>
      )}
    </motion.button>
  );
}

Open in interactive docs

LiquidGlassCalendar

Category: Data Display

Month calendar with highlighted and disabled dates.

Props

PropTypeRequiredDescription
value Date No Current value of the control.
onChange (date: Date) => void No Callback when the value changes.
className string No Additional Tailwind CSS classes.
minDate Date No -
maxDate Date No -
highlightedDates Date[] No -

Usage

<LiquidGlassCalendar />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";

interface LiquidGlassCalendarProps {
  value?: Date;
  onChange?: (date: Date) => void;
  className?: string;
  minDate?: Date;
  maxDate?: Date;
  highlightedDates?: Date[];
}

const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTHS = [
  "January", "February", "March", "April", "May", "June",
  "July", "August", "September", "October", "November", "December",
];

function isSameDay(a: Date, b: Date) {
  return (
    a.getFullYear() === b.getFullYear() &&
    a.getMonth() === b.getMonth() &&
    a.getDate() === b.getDate()
  );
}

export function LiquidGlassCalendar({
  value,
  onChange,
  className,
  minDate,
  maxDate,
  highlightedDates = [],
}: LiquidGlassCalendarProps) {
  const [viewDate, setViewDate] = useState(value || new Date());
  const today = new Date();

  const year = viewDate.getFullYear();
  const month = viewDate.getMonth();
  const firstDay = new Date(year, month, 1).getDay();
  const daysInMonth = new Date(year, month + 1, 0).getDate();

  const prevMonth = () => setViewDate(new Date(year, month - 1, 1));
  const nextMonth = () => setViewDate(new Date(year, month + 1, 1));

  const isHighlighted = (d: number) =>
    highlightedDates.some((h) =>
      isSameDay(h, new Date(year, month, d))
    );

  const isDisabled = (d: number) => {
    const date = new Date(year, month, d);
    if (minDate && date < minDate) return true;
    if (maxDate && date > maxDate) return true;
    return false;
  };

  const isSelected = (d: number) =>
    value && isSameDay(value, new Date(year, month, d));

  const isToday = (d: number) => isSameDay(today, new Date(year, month, d));

  return (
    <div
      className={cn(
        "w-full max-w-xs p-4 rounded-2xl",
        "glass-blur-sm glass-surface glass-border glass-highlight",
        className
      )}
    >
      {/* Header */}
      <div className="flex items-center justify-between mb-4">
        <motion.button
          whileHover={{ scale: 1.1 }}
          whileTap={{ scale: 0.9 }}
          onClick={prevMonth}
          className="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--lg-border-subtle)] text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors"
        >
          <ChevronLeft size={14} />
        </motion.button>
        <span className="text-sm font-semibold text-[var(--lg-text)]">
          {MONTHS[month]} {year}
        </span>
        <motion.button
          whileHover={{ scale: 1.1 }}
          whileTap={{ scale: 0.9 }}
          onClick={nextMonth}
          className="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--lg-border-subtle)] text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors"
        >
          <ChevronRight size={14} />
        </motion.button>
      </div>

      {/* Day headers */}
      <div className="grid grid-cols-7 gap-1 mb-1">
        {DAYS.map((d) => (
          <div key={d} className="text-center text-[10px] font-semibold uppercase tracking-wider text-[var(--lg-text-muted)] py-1">
            {d}
          </div>
        ))}
      </div>

      {/* Days */}
      <div className="grid grid-cols-7 gap-1">
        {Array.from({ length: firstDay }).map((_, i) => (
          <div key={`empty-${i}`} />
        ))}
        {Array.from({ length: daysInMonth }).map((_, i) => {
          const day = i + 1;
          const disabled = isDisabled(day);
          const selected = isSelected(day);
          const highlighted = isHighlighted(day);
          const todayMark = isToday(day);

          return (
            <motion.button
              key={day}
              whileHover={disabled ? {} : { scale: 1.15 }}
              whileTap={disabled ? {} : { scale: 0.9 }}
              onClick={() => !disabled && onChange?.(new Date(year, month, day))}
              disabled={disabled}
              className={cn(
                "relative flex h-8 w-8 items-center justify-center rounded-lg text-xs font-medium transition-all",
                selected
                  ? "bg-liquid-blue/30 text-white"
                  : todayMark
                  ? "text-liquid-blue font-bold"
                  : disabled
                  ? "text-[var(--lg-text-muted)] cursor-not-allowed"
                  : "text-[var(--lg-text-secondary)] hover:bg-[var(--lg-border)] hover:text-[var(--lg-text)]",
                highlighted && !selected && "ring-1 ring-liquid-amber/40"
              )}
            >
              {day}
              {todayMark && !selected && (
                <span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-0.5 w-3 rounded-full bg-liquid-blue" />
              )}
            </motion.button>
          );
        })}
      </div>
    </div>
  );
}

Open in interactive docs

LiquidGlassCard

Category: Layout & Surfaces

Basic glass card container with variants, padding, and hover lift.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
variant "default" | "thick" | "thin" | "chrome" No Visual variant to use.
hover boolean No -
padding "none" | "sm" | "md" | "lg" No -
border boolean No -
highlight boolean No -

Usage

<LiquidGlassCard />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import type { ReactNode } from "react";

interface LiquidGlassCardProps {
  children: ReactNode;
  className?: string;
  variant?: "default" | "thick" | "thin" | "chrome";
  hover?: boolean;
  padding?: "none" | "sm" | "md" | "lg";
  border?: boolean;
  highlight?: boolean;
}

const variantStyles = {
  default: "glass-blur glass-surface",
  thick: "glass-blur-lg glass-surface-strong",
  thin: "glass-blur-sm glass-surface",
  chrome: "glass-blur glass-surface-dark",
};

const paddingStyles = {
  none: "",
  sm: "p-3",
  md: "p-5",
  lg: "p-7",
};

export function LiquidGlassCard({
  children,
  className,
  variant = "default",
  hover = true,
  padding = "md",
  border = true,
  highlight = true,
}: LiquidGlassCardProps) {
  return (
    <motion.div
      whileHover={
        hover
          ? { scale: 1.01, y: -2, transition: { duration: 0.3, ease: "easeOut" } }
          : undefined
      }
      className={cn(
        "relative overflow-hidden rounded-3xl",
        variantStyles[variant],
        paddingStyles[padding],
        border && "glass-border",
        highlight && "glass-highlight",
        className
      )}
    >
      {/* Inner highlight line */}
      <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
      {/* Subtle reflection */}
      <div className="pointer-events-none absolute -top-20 -right-20 h-40 w-40 rounded-full bg-[var(--lg-reflection)] blur-3xl" />
      {children}
    </motion.div>
  );
}

Open in interactive docs

LiquidGlassCheckbox

Category: Inputs, Toggles & Pickers

Glass checkbox with indeterminate support.

Props

PropTypeRequiredDescription
checked boolean No -
indeterminate boolean No -
onChange (checked: boolean) => void No Callback when the value changes.
className string No Additional Tailwind CSS classes.
label string No Label text.
disabled boolean No Whether the component is disabled.
size "sm" | "md" | "lg" No Size preset.

Usage

<LiquidGlassCheckbox />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { Check, Minus } from "lucide-react";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { useGlassSurface } from "./useGlassSurface";
import { useLiquidTapScale, useLiquidTransition } from "./useLiquidMotion";

interface LiquidGlassCheckboxProps {
  checked?: boolean;
  indeterminate?: boolean;
  onChange?: (checked: boolean) => void;
  className?: string;
  label?: string;
  disabled?: boolean;
  size?: "sm" | "md" | "lg";
}

const sizeStyles = {
  sm: { box: "w-4 h-4 rounded-md", icon: 10 },
  md: { box: "w-5 h-5 rounded-lg", icon: 12 },
  lg: { box: "w-6 h-6 rounded-xl", icon: 14 },
};

export function LiquidGlassCheckbox({
  checked = false,
  indeterminate = false,
  onChange,
  className,
  label,
  disabled,
  size = "md",
}: LiquidGlassCheckboxProps) {
  const tapScale = useLiquidTapScale();
  const checkTransition = useLiquidTransition();
  const uncheckedFill = useGlassSurface({ variant: "fill", opacity: 0.05 });
  const s = sizeStyles[size];
  const isActive = checked || indeterminate;

  return (
    <label
      className={cn(
        "inline-flex items-center gap-3 cursor-pointer select-none",
        disabled && "opacity-40 cursor-not-allowed",
        className
      )}
    >
      <motion.div
        whileTap={disabled ? {} : { scale: tapScale }}
        onClick={() => !disabled && onChange?.(!checked)}
        className={cn(
          "relative flex items-center justify-center flex-shrink-0",
          "glass-blur-sm border transition-all duration-200",
          s.box,
          isActive
            ? "bg-liquid-blue/30 border-liquid-blue/50"
            : "border-white/10 hover:border-white/20"
        )}
        style={isActive ? undefined : { background: uncheckedFill.style.background }}
      >
        {/* Top highlight */}
        <GlassTopHighlight className="inset-x-1 top-0" opacity={0.2} />
        <AnimatePresence>
          {isActive && (
            <motion.div
              initial={{ scale: 0, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={{ scale: 0, opacity: 0 }}
              transition={checkTransition}
            >
              {indeterminate ? (
                <Minus size={s.icon} className="text-white" />
              ) : (
                <Check size={s.icon} className="text-white" strokeWidth={3} />
              )}
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>
      {label && (
        <span className={cn("text-sm", disabled ? "text-[var(--lg-text-muted)]" : "text-[var(--lg-text-secondary)]")}>
          {label}
        </span>
      )}
    </label>
  );
}


Open in interactive docs

LiquidGlassChip

Category: Media & Content

Filter/removable chip component.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
variant "default" | "primary" | "success" | "warning" | "danger" | "info" No Visual variant to use.
size "sm" | "md" No Size preset.
onRemove () => void No -
onClick () => void No Callback when the element is clicked.
active boolean No -
icon ReactNode No Icon element to display.

Usage

<LiquidGlassChip />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { X } from "lucide-react";
import { useLiquidPress } from "./useLiquidPress";
import { LiquidGlassPressSplash } from "./LiquidGlassPressSplash";

interface LiquidGlassChipProps {
  children: ReactNode;
  className?: string;
  variant?: "default" | "primary" | "success" | "warning" | "danger" | "info";
  size?: "sm" | "md";
  onRemove?: () => void;
  onClick?: () => void;
  active?: boolean;
  icon?: ReactNode;
}

const variantStyles = {
  default: {
    base: "bg-[var(--lg-border-subtle)] text-[var(--lg-text-secondary)] border-[var(--lg-border-subtle)] hover:bg-[var(--lg-border)]",
    active: "bg-[var(--lg-border)] text-[var(--lg-text)] border-[var(--lg-border)]",
  },
  primary: {
    base: "bg-liquid-blue/8 text-liquid-blue/70 border-liquid-blue/15 hover:bg-liquid-blue/15",
    active: "bg-liquid-blue/20 text-liquid-blue border-liquid-blue/30",
  },
  success: {
    base: "bg-liquid-emerald/8 text-liquid-emerald/70 border-liquid-emerald/15 hover:bg-liquid-emerald/15",
    active: "bg-liquid-emerald/20 text-liquid-emerald border-liquid-emerald/30",
  },
  warning: {
    base: "bg-liquid-amber/8 text-liquid-amber/70 border-liquid-amber/15 hover:bg-liquid-amber/15",
    active: "bg-liquid-amber/20 text-liquid-amber border-liquid-amber/30",
  },
  danger: {
    base: "bg-liquid-rose/8 text-liquid-rose/70 border-liquid-rose/15 hover:bg-liquid-rose/15",
    active: "bg-liquid-rose/20 text-liquid-rose border-liquid-rose/30",
  },
  info: {
    base: "bg-liquid-cyan/8 text-liquid-cyan/70 border-liquid-cyan/15 hover:bg-liquid-cyan/15",
    active: "bg-liquid-cyan/20 text-liquid-cyan border-liquid-cyan/30",
  },
};

const sizeStyles = {
  sm: "px-2.5 py-1 text-xs rounded-lg gap-1",
  md: "px-3 py-1.5 text-sm rounded-xl gap-1.5",
};

export function LiquidGlassChip({
  children,
  className,
  variant = "default",
  size = "md",
  onRemove,
  onClick,
  active = false,
  icon,
}: LiquidGlassChipProps) {
  const v = variantStyles[variant];
  const { state: press, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel } =
    useLiquidPress<HTMLButtonElement>(!onClick);

  return (
    <motion.button
      whileHover={{ scale: onClick ? 1.04 : 1 }}
      whileTap={{ scale: onClick ? 0.96 : 1 }}
      onClick={onClick}
      onPointerDown={onPointerDown}
      onPointerUp={onPointerUp}
      onPointerLeave={onPointerLeave}
      onPointerCancel={onPointerCancel}
      className={cn(
        "relative inline-flex items-center font-medium transition-all duration-200 overflow-hidden isolate",
        "glass-blur-sm border",
        sizeStyles[size],
        active ? v.active : v.base,
        onClick && "cursor-pointer",
        className
      )}
    >
      {/* Liquid-glass press splash */}
      {onClick && <LiquidGlassPressSplash press={press} size={90} />}

      {icon && <span className="relative z-10 flex-shrink-0">{icon}</span>}
      <span className="relative z-10">{children}</span>
      {onRemove && (
        <motion.span
          whileHover={{ scale: 1.2 }}
          whileTap={{ scale: 0.8 }}
          onClick={(e) => {
            e.stopPropagation();
            onRemove();
          }}
          className="relative z-10 ml-0.5 flex h-4 w-4 items-center justify-center rounded-full hover:bg-[var(--lg-border)] cursor-pointer"
        >
          <X size={12} />
        </motion.span>
      )}
    </motion.button>
  );
}

Open in interactive docs

LiquidGlassColorPicker

Category: Inputs, Toggles & Pickers

Color palette picker with custom hex input.

Props

PropTypeRequiredDescription
value string No Current value of the control.
onChange (color: string) => void No Callback when the value changes.
className string No Additional Tailwind CSS classes.
colors string[] No -
showCustom boolean No -

Usage

<LiquidGlassColorPicker />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { useState } from "react";

interface LiquidGlassColorPickerProps {
  value?: string;
  onChange?: (color: string) => void;
  className?: string;
  colors?: string[];
  showCustom?: boolean;
}

const defaultColors = [
  "#ef4444", "#f97316", "#f59e0b", "#84cc16", "#22c55e",
  "#10b981", "#06b6d4", "#3b82f6", "#6366f1", "#8b5cf6",
  "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#78716c",
];

export function LiquidGlassColorPicker({
  value,
  onChange,
  className,
  colors = defaultColors,
  showCustom = true,
}: LiquidGlassColorPickerProps) {
  const [customColor, setCustomColor] = useState(value || "#3b82f6");

  return (
    <div className={cn("w-full", className)}>
      <div className="grid grid-cols-5 gap-2">
        {colors.map((color) => (
          <motion.button
            key={color}
            whileHover={{ scale: 1.15 }}
            whileTap={{ scale: 0.9 }}
            onClick={() => onChange?.(color)}
            className={cn(
              "relative h-8 w-8 rounded-lg transition-all",
              value === color && "ring-2 ring-white/50 ring-offset-2 ring-offset-transparent"
            )}
            style={{ backgroundColor: color }}
          >
            {value === color && (
              <motion.div
                initial={{ scale: 0 }}
                animate={{ scale: 1 }}
                className="absolute inset-0 flex items-center justify-center"
              >
                <div className="h-2 w-2 rounded-full bg-white shadow-sm" />
              </motion.div>
            )}
          </motion.button>
        ))}
      </div>

      {showCustom && (
        <div className="mt-3 flex items-center gap-3">
          <div
            className="h-8 w-8 rounded-lg ring-1 ring-white/10 flex-shrink-0"
            style={{ backgroundColor: customColor }}
          />
          <div className="flex-1 flex items-center gap-2 glass-blur-sm glass-surface glass-border rounded-xl px-3 py-2">
            <span className="text-[var(--lg-text-muted)] text-xs">#</span>
            <input
              type="text"
              value={customColor.replace("#", "")}
              onChange={(e) => {
                const hex = e.target.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
                const full = `#${hex}`;
                setCustomColor(full);
                if (hex.length === 6) onChange?.(full);
              }}
              className="flex-1 bg-transparent text-sm text-[var(--lg-text-secondary)] outline-none uppercase"
              maxLength={6}
            />
            <input
              type="color"
              value={customColor}
              onChange={(e) => {
                setCustomColor(e.target.value);
                onChange?.(e.target.value);
              }}
              className="h-5 w-5 rounded cursor-pointer border-0 p-0 bg-transparent"
            />
          </div>
        </div>
      )}
    </div>
  );
}

Open in interactive docs

LiquidGlassCommandPalette

Category: Overlays, Menus & Tooltips

Cmd+K style searchable command palette.

Props

PropTypeRequiredDescription
isOpen boolean Yes Controlled open state.
onClose () => void Yes Callback when the component requests to close.
items CommandItem[] Yes -
placeholder string No Placeholder text.

Usage

<LiquidGlassCommandPalette />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useEffect, useRef, useMemo } from "react";
import { Search, Command } from "lucide-react";
import { GlassTopHighlight } from "./GlassTopHighlight";
import {
  useLiquidOverlayVariants,
  useLiquidTransition,
  useGlassOverlayRootStyle,
} from "./useLiquidMotion";

interface CommandItem {
  id: string;
  label: string;
  shortcut?: string;
  icon?: React.ReactNode;
  category?: string;
  onSelect?: () => void;
}

interface LiquidGlassCommandPaletteProps {
  isOpen: boolean;
  onClose: () => void;
  items: CommandItem[];
  placeholder?: string;
}

export function LiquidGlassCommandPalette({
  isOpen,
  onClose,
  items,
  placeholder = "Type a command or search...",
}: LiquidGlassCommandPaletteProps) {
  const overlayVariants = useLiquidOverlayVariants();
  const transition = useLiquidTransition();
  const overlayRef = useGlassOverlayRootStyle(isOpen);
  const [query, setQuery] = useState("");
  const [selectedIndex, setSelectedIndex] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);

  const filtered = useMemo(() => {
    if (!query.trim()) return items;
    const q = query.toLowerCase();
    return items.filter(
      (i) =>
        i.label.toLowerCase().includes(q) ||
        i.category?.toLowerCase().includes(q)
    );
  }, [query, items]);

  const grouped = useMemo(() => {
    const map = new Map<string, CommandItem[]>();
    filtered.forEach((item) => {
      const cat = item.category || "General";
      if (!map.has(cat)) map.set(cat, []);
      map.get(cat)!.push(item);
    });
    return map;
  }, [filtered]);

  useEffect(() => {
    if (isOpen) {
      setQuery("");
      setSelectedIndex(0);
      setTimeout(() => inputRef.current?.focus(), 100);
    }
  }, [isOpen]);

  useEffect(() => {
    const handleKey = (e: KeyboardEvent) => {
      if (!isOpen) return;
      if (e.key === "Escape") onClose();
      if (e.key === "ArrowDown") {
        e.preventDefault();
        setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
      }
      if (e.key === "ArrowUp") {
        e.preventDefault();
        setSelectedIndex((i) => Math.max(i - 1, 0));
      }
      if (e.key === "Enter") {
        e.preventDefault();
        const item = filtered[selectedIndex];
        if (item) {
          item.onSelect?.();
          onClose();
        }
      }
    };
    window.addEventListener("keydown", handleKey);
    return () => window.removeEventListener("keydown", handleKey);
  }, [isOpen, filtered, selectedIndex, onClose]);

  let globalIndex = 0;

  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          initial={{ opacity: 1 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          ref={overlayRef}
          className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] px-4"
          onClick={onClose}
        >
          <div className="glass-backdrop" />
          <motion.div
            {...overlayVariants}
            transition={transition}
            onClick={(e) => e.stopPropagation()}
            className="relative w-full max-w-xl overflow-hidden rounded-2xl glass-blur-xl glass-surface glass-border glass-highlight-strong"
          >
            {/* Top highlight */}
            <GlassTopHighlight className="inset-x-0 top-0" opacity={0.3} />

            {/* Search input */}
            <div className="flex items-center gap-3 px-5 py-4 border-b border-[var(--lg-border-subtle)]">
              <Search size={18} className="text-[var(--lg-text-muted)] flex-shrink-0" />
              <input
                ref={inputRef}
                value={query}
                onChange={(e) => {
                  setQuery(e.target.value);
                  setSelectedIndex(0);
                }}
                placeholder={placeholder}
                className="flex-1 bg-transparent text-[var(--lg-text)] placeholder-[var(--lg-text-muted)] outline-none text-sm"
              />
              <div className="flex items-center gap-1 px-2 py-1 rounded-md bg-[var(--lg-border-subtle)] text-[var(--lg-text-muted)] text-xs">
                <Command size={10} />
                <span>K</span>
              </div>
            </div>

            {/* Results */}
            <div className="max-h-[50vh] overflow-y-auto py-2">
              {filtered.length === 0 ? (
                <div className="px-5 py-8 text-center text-sm text-[var(--lg-text-muted)]">
                  No results found
                </div>
              ) : (
                Array.from(grouped.entries()).map(([category, catItems]) => (
                  <div key={category}>
                    <div className="px-5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--lg-text-muted)]">
                      {category}
                    </div>
                    {catItems.map((item) => {
                      const idx = globalIndex++;
                      const isSelected = idx === selectedIndex;
                      return (
                        <motion.button
                          key={item.id}
                          onMouseEnter={() => setSelectedIndex(idx)}
                          onClick={() => {
                            item.onSelect?.();
                            onClose();
                          }}
                          className={cn(
                            "flex w-full items-center gap-3 px-5 py-2.5 text-left transition-colors",
                            isSelected
                              ? "bg-[var(--lg-border)]"
                              : "hover:bg-[var(--lg-border-subtle)]"
                          )}
                        >
                          {item.icon && (
                            <span className="text-[var(--lg-text-muted)]">{item.icon}</span>
                          )}
                          <span className="flex-1 text-sm text-[var(--lg-text-secondary)]">
                            {item.label}
                          </span>
                          {item.shortcut && (
                            <span className="text-xs text-[var(--lg-text-muted)] px-1.5 py-0.5 rounded bg-[var(--lg-border-subtle)]">
                              {item.shortcut}
                            </span>
                          )}
                        </motion.button>
                      );
                    })}
                  </div>
                ))
              )}
            </div>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

Open in interactive docs

LiquidGlassContextMenu

Category: Overlays, Menus & Tooltips

Right-click context menu portaled to document.body.

Props

PropTypeRequiredDescription
children React.ReactNode Yes Child React nodes rendered inside the component.
items MenuItem[] Yes -
className string No Additional Tailwind CSS classes.

Usage

<LiquidGlassContextMenu />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useGlassSurface } from "./useGlassSurface";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { useGlassOverlayRootStyle, mergeRefs } from "./useLiquidMotion";

interface MenuItem {
  id: string;
  label: string;
  icon?: React.ReactNode;
  shortcut?: string;
  disabled?: boolean;
  separator?: boolean;
  onClick?: () => void;
}

interface LiquidGlassContextMenuProps {
  children: React.ReactNode;
  items: MenuItem[];
  className?: string;
}

export function LiquidGlassContextMenu({
  children,
  items,
  className,
}: LiquidGlassContextMenuProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const triggerRef = useRef<HTMLDivElement>(null);
  const menuRef = useRef<HTMLDivElement>(null);
  const overlayRef = useGlassOverlayRootStyle(isOpen);
  const popover = useGlassSurface({ variant: "popover" });

  const handleContextMenu = useCallback((e: React.MouseEvent) => {
    e.preventDefault();
    setPosition({ x: e.clientX, y: e.clientY });
    setIsOpen(true);
  }, []);

  useEffect(() => {
    if (!isOpen) return;

    const handleClick = (e: MouseEvent) => {
      const target = e.target as Node;
      if (
        !triggerRef.current?.contains(target) &&
        !menuRef.current?.contains(target)
      ) {
        setIsOpen(false);
      }
    };

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") setIsOpen(false);
    };

    window.addEventListener("mousedown", handleClick);
    window.addEventListener("keydown", handleKeyDown);
    return () => {
      window.removeEventListener("mousedown", handleClick);
      window.removeEventListener("keydown", handleKeyDown);
    };
  }, [isOpen]);

  // Clamp position to viewport once we know the menu size
  useEffect(() => {
    if (!isOpen || !menuRef.current) return;
    const rect = menuRef.current.getBoundingClientRect();
    const padding = 12;
    setPosition((pos) => ({
      x: Math.min(Math.max(pos.x, padding), window.innerWidth - rect.width - padding),
      y: Math.min(Math.max(pos.y, padding), window.innerHeight - rect.height - padding),
    }));
  }, [isOpen, items.length]);

  const menu = (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          ref={mergeRefs(menuRef, overlayRef)}
          initial={{ opacity: 0.2, scale: 0.95 }}
          animate={{ opacity: 1, scale: 1 }}
          exit={{ opacity: 0, scale: 0.95 }}
          transition={{ duration: 0.1 }}
          style={{left: position.x, top: position.y, ...popover.style}}
          className={cn(
            "fixed z-[100] min-w-[180px] rounded-xl overflow-hidden",
            popover.className
          )}
          onClick={(e) => e.stopPropagation()}
          onContextMenu={(e) => e.preventDefault()}
        >
          {/* Top highlight */}
          <GlassTopHighlight className="inset-x-0 top-0" opacity={0.2} />
          {items.map((item, i) =>
            item.separator ? (
              <div key={`sep-${i}`} className="my-1 h-px bg-[var(--lg-border-subtle)] mx-2" />
            ) : (
              <motion.button
                key={item.id}
                whileHover={item.disabled ? {} : { x: 2 }}
                onClick={() => {
                  if (!item.disabled) {
                    item.onClick?.();
                    setIsOpen(false);
                  }
                }}
                className={cn(
                  "flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
                  item.disabled
                    ? "text-[var(--lg-text-muted)] cursor-not-allowed"
                    : "text-[var(--lg-text-secondary)] hover:bg-[var(--lg-border)] hover:text-[var(--lg-text)]"
                )}
              >
                {item.icon && <span className="text-[var(--lg-text-muted)]">{item.icon}</span>}
                <span className="flex-1">{item.label}</span>
                {item.shortcut && (
                  <span className="text-[10px] text-[var(--lg-text-muted)] px-1.5 py-0.5 rounded bg-[var(--lg-border-subtle)]">
                    {item.shortcut}
                  </span>
                )}
              </motion.button>
            )
          )}
        </motion.div>
      )}
    </AnimatePresence>
  );

  return (
    <div ref={triggerRef} onContextMenu={handleContextMenu} className={cn("cursor-context-menu", className)}>
      {children}
      {createPortal(menu, document.body)}
    </div>
  );
}

Open in interactive docs

LiquidGlassControls

Category: Theme & Glass Primitives

Interactive panel to adjust blur, transparency, and saturation.

Props

PropTypeRequiredDescription
className string No Additional Tailwind CSS classes.

Usage

<LiquidGlassControls />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { useTheme } from "./ThemeProvider";
import { LiquidGlassSlider } from "./LiquidGlassSlider";
import { SlidersHorizontal, RotateCcw, Sparkles } from "lucide-react";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { profileLabels } from "./kube";
import type { KubeProfile } from "./kube";

interface LiquidGlassControlsProps {
  className?: string;
}

const profiles: KubeProfile[] = [
  "convex-circle",
  "convex-squircle",
  "concave",
  "lip",
];

export function LiquidGlassControls({ className }: LiquidGlassControlsProps) {
  const {
    mode,
    glass,
    setGlass,
    resetGlass,
    liquidGlass,
    setLiquidGlass,
    resetLiquidGlass,
  } = useTheme();

  const isLiquid = mode === "liquid-glass";

  return (
    <motion.div
      initial={{ opacity: 0, y: 10 }}
      animate={{ opacity: 1, y: 0 }}
      className={cn(
        "relative w-full max-w-xs rounded-3xl p-5 overflow-hidden",
        "glass-blur-xl glass-surface-strong glass-border",
        className
      )}
    >
      <GlassTopHighlight className="inset-x-4 top-0" opacity={0.3} />
      <div className="flex items-center justify-between mb-5">
        <div className="flex items-center gap-2">
          {isLiquid ? (
            <Sparkles size={16} className="text-liquid-blue" />
          ) : (
            <SlidersHorizontal size={16} className="text-liquid-blue" />
          )}
          <span className="text-sm font-semibold text-[var(--lg-text)]">
            {isLiquid ? "Liquid Glass Controls" : "Glass Controls"}
          </span>
        </div>
        <motion.button
          whileTap={{ scale: 0.9 }}
          onClick={isLiquid ? resetLiquidGlass : resetGlass}
          className="p-1.5 rounded-lg hover:bg-[var(--lg-border-subtle)] text-[var(--lg-text-muted)]"
        >
          <RotateCcw size={14} />
        </motion.button>
      </div>

      {isLiquid ? (
        <div className="space-y-5">
          <div>
            <span className="text-xs text-[var(--lg-text-secondary)] block mb-2">
              Surface profile
            </span>
            <div className="flex flex-wrap gap-2">
              {profiles.map((p) => (
                <button
                  key={p}
                  onClick={() => setLiquidGlass({ profile: p })}
                  className={cn(
                    "px-2.5 py-1 rounded-full text-[10px] font-medium transition-colors",
                    liquidGlass.profile === p
                      ? "bg-liquid-blue text-white"
                      : "bg-white/10 text-[var(--lg-text-secondary)] hover:bg-white/15"
                  )}
                >
                  {profileLabels[p]}
                </button>
              ))}
            </div>
          </div>

          <LiquidGlassSlider
            label="Bezel"
            value={liquidGlass.bezel}
            min={8}
            max={80}
            onChange={(v) => setLiquidGlass({ bezel: v })}
            valueFormatter={(v) => `${v}px`}
          />
          <LiquidGlassSlider
            label="Refraction"
            value={liquidGlass.refraction}
            min={0}
            max={100}
            onChange={(v) => setLiquidGlass({ refraction: v })}
            valueFormatter={(v) => `${v}%`}
          />
          <LiquidGlassSlider
            label="Thickness"
            value={liquidGlass.thickness}
            min={10}
            max={120}
            onChange={(v) => setLiquidGlass({ thickness: v })}
            valueFormatter={(v) => `${v}%`}
          />
          <LiquidGlassSlider
            label="Light angle"
            value={liquidGlass.lightAngle}
            min={-180}
            max={180}
            step={5}
            onChange={(v) => setLiquidGlass({ lightAngle: v })}
            valueFormatter={(v) => `${v}°`}
          />
          <LiquidGlassSlider
            label="Specular"
            value={liquidGlass.specularOpacity}
            min={0}
            max={100}
            onChange={(v) => setLiquidGlass({ specularOpacity: v })}
            valueFormatter={(v) => `${v}%`}
          />
          <LiquidGlassSlider
            label="Transparency"
            value={liquidGlass.transparency}
            min={0}
            max={100}
            onChange={(v) => setLiquidGlass({ transparency: v })}
            valueFormatter={(v) => `${v}%`}
          />
          <LiquidGlassSlider
            label="Blur"
            value={liquidGlass.blur}
            min={0}
            max={100}
            onChange={(v) => setLiquidGlass({ blur: v })}
            valueFormatter={(v) => `${v}%`}
          />
          <LiquidGlassSlider
            label="Saturation"
            value={liquidGlass.saturation}
            min={0}
            max={200}
            onChange={(v) => setLiquidGlass({ saturation: v })}
            valueFormatter={(v) => `${v}%`}
          />
        </div>
      ) : (
        <div className="space-y-5">
          <LiquidGlassSlider
            label="Blur"
            value={glass.blur}
            onChange={(v) => setGlass({ blur: v })}
            valueFormatter={(v) => `${v}%`}
          />
          <LiquidGlassSlider
            label="Transparency"
            value={glass.transparency}
            onChange={(v) => setGlass({ transparency: v })}
            valueFormatter={(v) => `${v}%`}
          />
          <LiquidGlassSlider
            label="Saturation"
            value={glass.saturation}
            onChange={(v) => setGlass({ saturation: v })}
            valueFormatter={(v) => `${v}%`}
          />
        </div>
      )}

      <div className={cn("mt-5 pt-4 border-t border-[var(--lg-border)] grid gap-2 text-center", isLiquid ? "grid-cols-5" : "grid-cols-3")}>
        {isLiquid ? (
          <>
            <div className="space-y-0.5">
              <div className="text-xs font-semibold text-[var(--lg-text)]">
                {profileLabels[liquidGlass.profile]}
              </div>
              <div className="text-[10px] text-[var(--lg-text-muted)]">Profile</div>
            </div>
            <div className="space-y-0.5">
              <div className="text-xs font-semibold text-[var(--lg-text)]">
                {liquidGlass.bezel}px
              </div>
              <div className="text-[10px] text-[var(--lg-text-muted)]">Bezel</div>
            </div>
            <div className="space-y-0.5">
              <div className="text-xs font-semibold text-[var(--lg-text)]">
                {liquidGlass.refraction}%
              </div>
              <div className="text-[10px] text-[var(--lg-text-muted)]">Refract</div>
            </div>
            <div className="space-y-0.5">
              <div className="text-xs font-semibold text-[var(--lg-text)]">
                {liquidGlass.thickness}%
              </div>
              <div className="text-[10px] text-[var(--lg-text-muted)]">Thick</div>
            </div>
            <div className="space-y-0.5">
              <div className="text-xs font-semibold text-[var(--lg-text)]">
                {liquidGlass.saturation}%
              </div>
              <div className="text-[10px] text-[var(--lg-text-muted)]">Sat</div>
            </div>
          </>
        ) : (
          <>
            {[
              {
                label: "Blur",
                value: `${Math.round((glass.blur / 100) * 60)}px`,
              },
              {
                label: "Alpha",
                value: `${Math.round((glass.transparency / 100) * 80)}%`,
              },
              {
                label: "Sat",
                value: `${glass.saturation}%`,
              },
            ].map((stat) => (
              <div key={stat.label} className="space-y-0.5">
                <div className="text-xs font-semibold text-[var(--lg-text)]">
                  {stat.value}
                </div>
                <div className="text-[10px] text-[var(--lg-text-muted)]">
                  {stat.label}
                </div>
              </div>
            ))}
          </>
        )}
      </div>
    </motion.div>
  );
}

Open in interactive docs

LiquidGlassDock

Category: Navigation

macOS-style floating dock.

Props

PropTypeRequiredDescription
items DockItem[] Yes -
className string No Additional Tailwind CSS classes.
position "bottom" | "top" | "left" | "right" No -

Usage

<LiquidGlassDock />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { GlassTopHighlight } from "./GlassTopHighlight";

interface DockItem {
  id: string;
  icon: ReactNode;
  label: string;
  onClick?: () => void;
  badge?: number;
  active?: boolean;
}

interface LiquidGlassDockProps {
  items: DockItem[];
  className?: string;
  position?: "bottom" | "top" | "left" | "right";
}

export function LiquidGlassDock({
  items,
  className,
  position = "bottom",
}: LiquidGlassDockProps) {
  const isVertical = position === "left" || position === "right";

  return (
    <div
      className={cn(
        "fixed z-50",
        position === "bottom" && "bottom-4 left-1/2 -translate-x-1/2",
        position === "top" && "top-4 left-1/2 -translate-x-1/2",
        position === "left" && "left-4 top-1/2 -translate-y-1/2",
        position === "right" && "right-4 top-1/2 -translate-y-1/2",
        className
      )}
    >
      <div
        className={cn(
          "flex items-center gap-1 p-2 rounded-2xl",
          "glass-blur-xl glass-surface glass-border glass-highlight-strong",
          isVertical ? "flex-col" : "flex-row"
        )}
      >
        {/* Top highlight */}
        <GlassTopHighlight className="inset-x-0 top-0 rounded-t-2xl" opacity={0.2} />

        {items.map((item) => (
          <motion.button
            key={item.id}
            whileHover={{ scale: 1.15, y: isVertical ? 0 : -4 }}
            whileTap={{ scale: 0.9 }}
            onClick={item.onClick}
            className={cn(
              "relative flex items-center justify-center rounded-xl transition-colors",
              isVertical ? "w-11 h-11" : "w-12 h-12",
              item.active
                ? "bg-[var(--lg-border)]"
                : "hover:bg-[var(--lg-border-subtle)]"
            )}
          >
            <span
              className={cn(
                "transition-colors",
                item.active ? "text-[var(--lg-text)]" : "text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]"
              )}
            >
              {item.icon}
            </span>
            {/* Tooltip on hover */}
            <div
              className={cn(
                "absolute opacity-0 group-hover:opacity-100 pointer-events-none",
                "px-2 py-1 rounded-lg glass-blur-sm glass-surface glass-border",
                "text-[10px] font-medium text-[var(--lg-text-secondary)] whitespace-nowrap",
                "transition-opacity",
                position === "bottom" && "bottom-full mb-2",
                position === "top" && "top-full mt-2",
                position === "left" && "left-full ml-2",
                position === "right" && "right-full mr-2"
              )}
            >
              {item.label}
            </div>
          </motion.button>
        ))}
      </div>
    </div>
  );
}

Open in interactive docs

LiquidGlassDrawer

Category: Modals, Drawers & Sheets

Side drawer that slides in from left or right.

Props

PropTypeRequiredDescription
isOpen boolean Yes Controlled open state.
onClose () => void Yes Callback when the component requests to close.
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
position "left" | "right" No -
width string No -
title string No Title text.

Usage

<LiquidGlassDrawer />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import type { ReactNode } from "react";
import { GlassTopHighlight } from "./GlassTopHighlight";
import {
  useLiquidSlideVariants,
  useLiquidTransition,
  useGlassOverlayRootStyle,
} from "./useLiquidMotion";

interface LiquidGlassDrawerProps {
  isOpen: boolean;
  onClose: () => void;
  children: ReactNode;
  className?: string;
  position?: "left" | "right";
  width?: string;
  title?: string;
}

export function LiquidGlassDrawer({
  isOpen,
  onClose,
  children,
  className,
  position = "right",
  width = "400px",
  title,
}: LiquidGlassDrawerProps) {
  const isLeft = position === "left";
  const slideVariants = useLiquidSlideVariants(position, { stiff: true });
  const transition = useLiquidTransition({ stiff: true });
  const overlayRef = useGlassOverlayRootStyle(isOpen);

  return (
    <AnimatePresence>
      {isOpen && (
        <div className="fixed inset-0 z-50" ref={overlayRef}>
          {/* Backdrop */}
          <motion.div
            initial={{ opacity: 0.2 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
            onClick={onClose}
            className="glass-backdrop-subtle"
          />
          {/* Drawer */}
          <motion.div
            {...slideVariants}
            transition={transition}
            className={cn(
              "absolute top-0 bottom-0",
              isLeft ? "left-0" : "right-0",
              "glass-blur-xl glass-surface glass-border",
              "flex flex-col overflow-hidden",
              className
            )}
            style={{ width, maxWidth: "90vw" }}
          >
            {/* Top highlight */}
            <GlassTopHighlight className="inset-x-0 top-0 z-10" opacity={0.25} />
            {/* Reflection */}
            <div className="pointer-events-none absolute -top-10 -right-10 h-32 w-32 rounded-full bg-[var(--lg-border-subtle)] blur-2xl" />

            {title && (
              <div className="flex items-center justify-between px-6 py-5 border-b border-[var(--lg-border-subtle)]">
                <h3 className="text-lg font-semibold text-[var(--lg-text)]">{title}</h3>
              </div>
            )}
            <div className="flex-1 overflow-y-auto p-6">{children}</div>
          </motion.div>
        </div>
      )}
    </AnimatePresence>
  );
}

Open in interactive docs

LiquidGlassEmptyState

Category: Feedback & Status

Empty state illustration with icon variants.

Props

PropTypeRequiredDescription
className string No Additional Tailwind CSS classes.
icon React.ReactNode No Icon element to display.
title string No Title text.
description string No Description text.
action React.ReactNode No -
variant "search" | "folder" | "inbox" | "error" | "custom" No Visual variant to use.

Usage

<LiquidGlassEmptyState />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { Search, FolderOpen, Inbox, FileX } from "lucide-react";

interface LiquidGlassEmptyStateProps {
  className?: string;
  icon?: React.ReactNode;
  title?: string;
  description?: string;
  action?: React.ReactNode;
  variant?: "search" | "folder" | "inbox" | "error" | "custom";
}

const variantIcons = {
  search: <Search size={40} className="text-[var(--lg-text-muted)]" />,
  folder: <FolderOpen size={40} className="text-[var(--lg-text-muted)]" />,
  inbox: <Inbox size={40} className="text-[var(--lg-text-muted)]" />,
  error: <FileX size={40} className="text-[var(--lg-text-muted)]" />,
  custom: null,
};

export function LiquidGlassEmptyState({
  className,
  icon,
  title = "No results found",
  description = "Try adjusting your search or filters to find what you're looking for.",
  action,
  variant = "search",
}: LiquidGlassEmptyStateProps) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 10 }}
      animate={{ opacity: 1, y: 0 }}
      className={cn(
        "flex flex-col items-center justify-center py-12 px-6 text-center",
        className
      )}
    >
      <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--lg-border-subtle)] mb-4">
        {icon || variantIcons[variant]}
      </div>
      <h3 className="text-base font-semibold text-[var(--lg-text-secondary)] mb-1">{title}</h3>
      <p className="text-sm text-[var(--lg-text-muted)] max-w-xs leading-relaxed mb-5">{description}</p>
      {action}
    </motion.div>
  );
}

Open in interactive docs

LiquidGlassFileUpload

Category: Inputs, Toggles & Pickers

Drag-and-drop file upload with file list.

Props

PropTypeRequiredDescription
onFilesSelected (files: File[]) => void No -
className string No Additional Tailwind CSS classes.
accept string No -
multiple boolean No -

Usage

<LiquidGlassFileUpload />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { Upload, X, Image, FileText, Music, Video } from "lucide-react";
import { useState, useCallback } from "react";

interface LiquidGlassFileUploadProps {
  onFilesSelected?: (files: File[]) => void;
  className?: string;
  accept?: string;
  multiple?: boolean;
  maxSize?: number; // in MB
}

function getFileIcon(type: string) {
  if (type.startsWith("image/")) return <Image size={16} className="text-liquid-purple" />;
  if (type.startsWith("video/")) return <Video size={16} className="text-liquid-rose" />;
  if (type.startsWith("audio/")) return <Music size={16} className="text-liquid-cyan" />;
  return <FileText size={16} className="text-liquid-blue" />;
}

function formatSize(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export function LiquidGlassFileUpload({
  onFilesSelected,
  className,
  accept,
  multiple = false,
  maxSize = 10,
}: LiquidGlassFileUploadProps) {
  const [files, setFiles] = useState<File[]>([]);
  const [isDragOver, setIsDragOver] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleFiles = useCallback(
    (newFiles: FileList | null) => {
      if (!newFiles) return;
      setError(null);
      const fileArray = Array.from(newFiles);
      const oversized = fileArray.filter((f) => f.size > maxSize * 1024 * 1024);
      if (oversized.length > 0) {
        setError(`Some files exceed ${maxSize}MB limit`);
        return;
      }
      const updated = multiple ? [...files, ...fileArray] : fileArray;
      setFiles(updated);
      onFilesSelected?.(updated);
    },
    [files, multiple, maxSize, onFilesSelected]
  );

  const removeFile = (index: number) => {
    const updated = files.filter((_, i) => i !== index);
    setFiles(updated);
    onFilesSelected?.(updated);
  };

  return (
    <div className={cn("w-full", className)}>
      <motion.div
        onDragOver={(e) => {
          e.preventDefault();
          setIsDragOver(true);
        }}
        onDragLeave={() => setIsDragOver(false)}
        onDrop={(e) => {
          e.preventDefault();
          setIsDragOver(false);
          handleFiles(e.dataTransfer.files);
        }}
        animate={{
          scale: isDragOver ? 1.02 : 1,
          borderColor: isDragOver ? "rgba(59, 130, 246, 0.4)" : undefined,
        }}
        className={cn(
          "relative flex flex-col items-center justify-center gap-3 p-8 rounded-2xl",
          "glass-blur-sm glass-surface glass-border border-dashed",
          "transition-colors cursor-pointer",
          isDragOver && "bg-liquid-blue/5"
        )}
      >
        <input
          type="file"
          accept={accept}
          multiple={multiple}
          onChange={(e) => handleFiles(e.target.files)}
          className="absolute inset-0 opacity-0 cursor-pointer"
        />
        <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--lg-border-subtle)]">
          <Upload size={20} className="text-[var(--lg-text-muted)]" />
        </div>
        <div className="text-center">
          <p className="text-sm text-[var(--lg-text-secondary)]">
            <span className="text-liquid-blue">Click to upload</span> or drag and drop
          </p>
          <p className="text-xs text-[var(--lg-text-muted)] mt-1">
            Max file size: {maxSize}MB
          </p>
        </div>
      </motion.div>

      {error && (
        <p className="mt-2 text-xs text-liquid-rose">{error}</p>
      )}

      <AnimatePresence>
        {files.length > 0 && (
          <motion.div
            initial={{ opacity: 0, height: 0 }}
            animate={{ opacity: 1, height: "auto" }}
            exit={{ opacity: 0, height: 0 }}
            className="mt-3 space-y-2"
          >
            {files.map((file, i) => (
              <motion.div
                key={`${file.name}-${i}`}
                initial={{ opacity: 0, x: -10 }}
                animate={{ opacity: 1, x: 0 }}
                exit={{ opacity: 0, x: 10 }}
                className={cn(
                  "flex items-center gap-3 px-3 py-2 rounded-xl",
                  "glass-blur-sm glass-surface glass-border"
                )}
              >
                <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--lg-border-subtle)]">
                  {getFileIcon(file.type)}
                </div>
                <div className="flex-1 min-w-0">
                  <p className="text-xs font-medium text-[var(--lg-text-secondary)] truncate">{file.name}</p>
                  <p className="text-[10px] text-[var(--lg-text-muted)]">{formatSize(file.size)}</p>
                </div>
                <motion.button
                  whileHover={{ scale: 1.1 }}
                  whileTap={{ scale: 0.9 }}
                  onClick={() => removeFile(i)}
                  className="text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors"
                >
                  <X size={14} />
                </motion.button>
              </motion.div>
            ))}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Open in interactive docs

LiquidGlassFluidCard

Category: Layout & Surfaces

Card with a cursor-following liquid refraction glow.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
padding "none" | "sm" | "md" | "lg" No -
variant "default" | "strong" | "ios26" No Visual variant to use.
onClick () => void No Callback when the element is clicked.

Usage

<LiquidGlassFluidCard />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { useState, type ReactNode, type MouseEvent } from "react";
import { GlassTopHighlight } from "./GlassTopHighlight";
import {
  useLiquidHoverLift,
  useLiquidTapScale,
  useLiquidTransition,
} from "./useLiquidMotion";

interface LiquidGlassFluidCardProps {
  children: ReactNode;
  className?: string;
  padding?: "none" | "sm" | "md" | "lg";
  variant?: "default" | "strong" | "ios26";
  onClick?: () => void;
}

export function LiquidGlassFluidCard({
  children,
  className,
  padding = "md",
  variant = "default",
  onClick,
}: LiquidGlassFluidCardProps) {
  const hoverLift = useLiquidHoverLift();
  const tapScale = useLiquidTapScale();
  const glowTransition = useLiquidTransition();
  const [glow, setGlow] = useState({ x: 50, y: 50, active: false });

  const handleMove = (e: MouseEvent<HTMLDivElement>) => {
    const rect = e.currentTarget.getBoundingClientRect();
    setGlow({
      x: ((e.clientX - rect.left) / rect.width) * 100,
      y: ((e.clientY - rect.top) / rect.height) * 100,
      active: true,
    });
  };

  const paddingClasses = {
    none: "",
    sm: "p-3",
    md: "p-5",
    lg: "p-7",
  };

  const isIos26 = variant === "ios26";

  return (
    <motion.div
      onMouseMove={handleMove}
      onMouseLeave={() => setGlow((g) => ({ ...g, active: false }))}
      onClick={onClick}
      whileHover={{ y: hoverLift }}
      whileTap={onClick ? { scale: tapScale } : undefined}
      className={cn(
        "relative overflow-hidden rounded-3xl isolate",
        isIos26 ? "glass-blur-xl glass-surface-strong glass-border" : "glass-blur-lg glass-surface glass-border",
        "shadow-xl shadow-black/10",
        paddingClasses[padding],
        onClick && "cursor-pointer",
        className
      )}
    >
      {/* Liquid refraction follows cursor */}
      <motion.div
        animate={{
          opacity: glow.active ? 0.6 : 0,
          scale: glow.active ? 1 : 0.8,
        }}
        transition={glowTransition}
        className="pointer-events-none absolute -inset-8 z-0"
        style={{
          background: `radial-gradient(circle at ${glow.x}% ${glow.y}%, rgba(255,255,255,0.12) 0%, transparent 45%)`,
        }}
      />

      {/* Top highlight */}
      <GlassTopHighlight className="inset-x-4 top-0 z-10" opacity={0.3} />

      {/* Subtle border glow */}
      {isIos26 && (
        <div className="pointer-events-none absolute inset-0 rounded-3xl border border-[var(--lg-border-subtle)] z-10" />
      )}

      <div className="relative z-10">{children}</div>
    </motion.div>
  );
}

Open in interactive docs

LiquidGlassHoverCard

Category: Overlays, Menus & Tooltips

Hover-triggered popover portaled to document.body.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
content ReactNode Yes -
className string No Additional Tailwind CSS classes.
delay number No -
width string No -

Usage

<LiquidGlassHoverCard />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useRef, useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useGlassSurface } from "./useGlassSurface";
import { useGlassOverlayRootStyle } from "./useLiquidMotion";

interface LiquidGlassHoverCardProps {
  children: ReactNode;
  content: ReactNode;
  className?: string;
  delay?: number;
  width?: string;
}

export function LiquidGlassHoverCard({
  children,
  content,
  className,
  delay = 0.3,
  width = "280px",
}: LiquidGlassHoverCardProps) {
  const triggerRef = useRef<HTMLDivElement>(null);
  const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const [isVisible, setIsVisible] = useState(false);
  const [position, setPosition] = useState({ left: 0, top: 0, width: 0 });

  const popover = useGlassSurface({ variant: "popover" });
  const topHighlight = useGlassSurface({ variant: "highlight", opacity: 0.20 });
  const arrowFill = useGlassSurface({ variant: "fill", opacity: 0.12 });
  const overlayRef = useGlassOverlayRootStyle(isVisible);

  const measurePosition = () => {
    if (!triggerRef.current) return;
    const rect = triggerRef.current.getBoundingClientRect();
    setPosition({ left: rect.left, top: rect.bottom + 8, width: rect.width });
  };

  const scheduleShow = () => {
    if (showTimerRef.current) clearTimeout(showTimerRef.current);
    if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
    measurePosition();
    showTimerRef.current = setTimeout(() => setIsVisible(true), delay * 1000);
  };

  const scheduleHide = () => {
    if (showTimerRef.current) clearTimeout(showTimerRef.current);
    if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
    hideTimerRef.current = setTimeout(() => setIsVisible(false), 100);
  };

  useEffect(() => {
    return () => {
      if (showTimerRef.current) clearTimeout(showTimerRef.current);
      if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
    };
  }, []);

  const popoverNode = (
    <AnimatePresence>
      {isVisible && (
        <motion.div
          initial={{ opacity: 0.2, y: 8, scale: 0.96 }}
          animate={{ opacity: 1, y: 0, scale: 1 }}
          exit={{ opacity: 0, y: 8, scale: 0.96 }}
          transition={{ duration: 0.15 }}
          className="fixed z-[100] flex justify-center pointer-events-none"
          ref={overlayRef} style={{left: position.left,
            top: position.top,
            width: position.width}}
        >
          <div
            className={cn(
              "pointer-events-auto flex-shrink-0",
              popover.className,
              "rounded-2xl overflow-hidden"
            )}
            style={{ width, ...popover.style }}
            onMouseEnter={scheduleShow}
            onMouseLeave={scheduleHide}
          >
            {/* Reflection blob */}
            <div className="pointer-events-none absolute -top-10 -right-10 h-24 w-24 rounded-full glass-reflection blur-2xl" />
            {/* Top highlight */}
            <div className={topHighlight.className} style={topHighlight.style} />
            {/* Arrow */}
            <div
              className="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 rotate-45 border-l border-t border-[var(--lg-border-subtle)]"
              style={{ background: arrowFill.style.background }}
            />
            <div className="relative p-4">{content}</div>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );

  return (
    <div
      ref={triggerRef}
      className={cn("relative inline-flex", className)}
      onMouseEnter={scheduleShow}
      onMouseLeave={scheduleHide}
    >
      {children}
      {createPortal(popoverNode, document.body)}
    </div>
  );
}

Open in interactive docs

LiquidGlassInput

Category: Inputs, Toggles & Pickers

Glass text input with icons, label, and error states.

Props

PropTypeRequiredDescription
placeholder string No Placeholder text.
value string No Current value of the control.
onChange (value: string) => void No Callback when the value changes.
className string No Additional Tailwind CSS classes.
icon ReactNode No Icon element to display.
iconRight ReactNode No -
type string No Input type or visual variant.
disabled boolean No Whether the component is disabled.
error string No -
label string No Label text.
size "sm" | "md" | "lg" No Size preset.

Usage

<LiquidGlassInput />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import type { ReactNode } from "react";

interface LiquidGlassInputProps {
  placeholder?: string;
  value?: string;
  onChange?: (value: string) => void;
  className?: string;
  icon?: ReactNode;
  iconRight?: ReactNode;
  type?: string;
  disabled?: boolean;
  error?: string;
  label?: string;
  size?: "sm" | "md" | "lg";
}

const sizeStyles = {
  sm: "px-3 py-2 text-sm rounded-xl",
  md: "px-4 py-3 text-sm rounded-2xl",
  lg: "px-5 py-4 text-base rounded-2xl",
};

export function LiquidGlassInput({
  placeholder,
  value,
  onChange,
  className,
  icon,
  iconRight,
  type = "text",
  disabled,
  error,
  label,
  size = "md",
}: LiquidGlassInputProps) {
  return (
    <div className={cn("w-full", className)}>
      {label && (
        <label className="mb-1.5 block text-sm font-medium text-[var(--lg-text-secondary)]">
          {label}
        </label>
      )}
      <motion.div
        whileFocus={{ scale: 1.01 }}
        className={cn(
          "relative flex items-center gap-3",
          "glass-blur-sm glass-surface glass-border glass-inner-glow",
          "transition-all duration-200",
          "focus-within:ring-2 focus-within:ring-white/20 focus-within:border-[var(--lg-border)]",
          error && "border-liquid-rose/50 ring-1 ring-liquid-rose/30",
          sizeStyles[size],
          disabled && "opacity-50 cursor-not-allowed"
        )}
      >
        {icon && (
          <span className="flex-shrink-0 text-[var(--lg-text-muted)]">{icon}</span>
        )}
        <input
          type={type}
          value={value}
          onChange={(e) => onChange?.(e.target.value)}
          placeholder={placeholder}
          disabled={disabled}
          className={cn(
            "w-full bg-transparent text-[var(--lg-text)] placeholder-[var(--lg-text-muted)] outline-none",
            disabled && "cursor-not-allowed"
          )}
        />
        {iconRight && (
          <span className="flex-shrink-0 text-[var(--lg-text-muted)]">{iconRight}</span>
        )}
        {/* Top highlight */}
        <div className="pointer-events-none absolute inset-x-3 top-0 h-px bg-[var(--lg-border)] rounded-full" />
      </motion.div>
      {error && (
        <p className="mt-1.5 text-xs text-liquid-rose">{error}</p>
      )}
    </div>
  );
}

Open in interactive docs

LiquidGlassIos26Button

Category: Buttons & FABs

iOS 26 inspired chrome button with rich glass effects.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
size "sm" | "md" | "lg" No Size preset.
icon ReactNode No Icon element to display.
disabled boolean No Whether the component is disabled.
onClick () => void No Callback when the element is clicked.
fullWidth boolean No -

Usage

<LiquidGlassIos26Button />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useCallback, type ReactNode, type MouseEvent } from "react";
import { useLiquidPress } from "./useLiquidPress";
import { LiquidGlassPressSplash } from "./LiquidGlassPressSplash";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { useLiquidTapScale } from "./useLiquidMotion";

interface LiquidGlassIos26ButtonProps {
  children: ReactNode;
  className?: string;
  size?: "sm" | "md" | "lg";
  icon?: ReactNode;
  disabled?: boolean;
  onClick?: () => void;
  fullWidth?: boolean;
}

export function LiquidGlassIos26Button({
  children,
  className,
  size = "md",
  icon,
  disabled,
  onClick,
  fullWidth,
}: LiquidGlassIos26ButtonProps) {
  const tapScale = useLiquidTapScale();
  const [ripples, setRipples] = useState<{ id: number; x: number; y: number }[]>([]);
  const { state: press, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel } =
    useLiquidPress<HTMLButtonElement>(disabled);

  const createRipple = useCallback(
    (e: MouseEvent<HTMLButtonElement>) => {
      if (disabled) return;
      const rect = e.currentTarget.getBoundingClientRect();
      const id = Date.now();
      setRipples((prev) => [
        ...prev,
        { id, x: e.clientX - rect.left, y: e.clientY - rect.top },
      ]);
      setTimeout(() => setRipples((prev) => prev.filter((r) => r.id !== id)), 900);
    },
    [disabled]
  );

  const sizeClasses = {
    sm: "px-4 py-2 text-sm rounded-2xl gap-1.5",
    md: "px-6 py-3 text-sm rounded-3xl gap-2",
    lg: "px-8 py-4 text-base rounded-3xl gap-2.5",
  };

  return (
    <motion.button
      whileHover={{ scale: disabled ? 1 : 1.03 }}
      whileTap={{ scale: disabled ? 1 : tapScale }}
      disabled={disabled}
      onPointerDown={onPointerDown}
      onPointerUp={onPointerUp}
      onPointerLeave={onPointerLeave}
      onPointerCancel={onPointerCancel}
      onClick={(e) => {
        createRipple(e);
        onClick?.();
      }}
      className={cn(
        "relative inline-flex items-center justify-center font-semibold overflow-hidden isolate",
        "glass-blur-lg glass-surface-strong glass-border",
        "text-[var(--lg-text)] shadow-2xl shadow-black/20",
        sizeClasses[size],
        fullWidth && "w-full",
        disabled && "opacity-50 cursor-not-allowed",
        className
      )}
    >
      {/* Chrome gradient */}
      <div className="absolute inset-0 bg-gradient-to-br from-liquid-blue/25 via-liquid-purple/15 to-liquid-pink/10 opacity-80" />

      {/* Top reflection line */}
      <GlassTopHighlight className="inset-x-3 top-0.5 z-20" opacity={0.4} />

      {/* Side refraction highlights */}
      <div className="pointer-events-none absolute -top-6 -left-4 h-16 w-16 rounded-full bg-[var(--lg-border)] blur-2xl" />
      <div className="pointer-events-none absolute -bottom-6 -right-4 h-16 w-16 rounded-full bg-liquid-blue/15 blur-2xl" />

      {/* Liquid-glass press splash */}
      <LiquidGlassPressSplash press={press} size={160} />

      {/* Press fluid compression */}
      <motion.div
        animate={{ opacity: press.isPressed ? 0.55 : 0 }}
        transition={{ duration: 0.15 }}
        className="absolute inset-0 z-0"
        style={{
          boxShadow:
            "inset 0 8px 24px rgba(0,0,0,0.28), inset 0 -2px 8px rgba(255,255,255,0.14)",
        }}
      />

      <AnimatePresence>
        {ripples.map((ripple) => (
          <motion.span
            key={ripple.id}
            initial={{
              scale: 0,
              opacity: 0.5,
              borderRadius: "45% 55% 50% 50% / 55% 45% 50% 50%",
            }}
            animate={{ scale: 3, opacity: 0, borderRadius: "50%" }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] }}
            className="absolute pointer-events-none z-10"
            style={{
              left: ripple.x,
              top: ripple.y,
              width: 60,
              height: 60,
              marginLeft: -30,
              marginTop: -30,
              background:
                "radial-gradient(circle, rgba(255,255,255,0.35) 0%, transparent 65%)",
            }}
          />
        ))}
      </AnimatePresence>

      {icon && <span className="relative z-20">{icon}</span>}
      <span className="relative z-20">{children}</span>
    </motion.button>
  );
}

Open in interactive docs

LiquidGlassKanban

Category: Data Display

Drag-and-drop kanban board.

Props

PropTypeRequiredDescription
columns KanbanColumn[] Yes -
className string No Additional Tailwind CSS classes.
onTaskMove (taskId: string, fromColumn: string, toColumn: string) => void No -

Usage

<LiquidGlassKanban />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { Plus, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { GlassSheen } from "./GlassSheen";
import { useGlassSurface } from "./useGlassSurface";

interface KanbanTask {
  id: string;
  title: string;
  description?: string;
  tag?: string;
  tagColor?: string;
  assignees?: string[];
}

interface KanbanColumn {
  id: string;
  title: string;
  tasks: KanbanTask[];
  color?: string;
}

interface LiquidGlassKanbanProps {
  columns: KanbanColumn[];
  className?: string;
  onTaskMove?: (taskId: string, fromColumn: string, toColumn: string) => void;
}

export function LiquidGlassKanban({
  columns: initialColumns,
  className,
  onTaskMove,
}: LiquidGlassKanbanProps) {
  const tagFill = useGlassSurface({ variant: "fill", opacity: 0.08 });
  const [columns, setColumns] = useState(initialColumns);
  const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
  const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null);

  const handleTaskDragStart = (e: React.DragEvent<HTMLDivElement>, taskId: string) => {
    setDraggedTaskId(taskId);
    e.dataTransfer.setData("text/plain", taskId);
    e.dataTransfer.effectAllowed = "move";
  };

  const handleColumnDragOver = (e: React.DragEvent<HTMLDivElement>, columnId: string) => {
    e.preventDefault();
    setDragOverColumnId(columnId);
  };

  const handleColumnDrop = (e: React.DragEvent<HTMLDivElement>, targetColumnId: string) => {
    e.preventDefault();
    const taskId = e.dataTransfer.getData("text/plain");
    if (!taskId) return;

    setDragOverColumnId(null);
    setDraggedTaskId(null);

    setColumns((prev) => {
      const sourceColumn = prev.find((c) => c.tasks.some((t) => t.id === taskId));
      if (!sourceColumn || sourceColumn.id === targetColumnId) return prev;

      const task = sourceColumn.tasks.find((t) => t.id === taskId)!;
      const next = prev.map((c) => {
        if (c.id === sourceColumn.id) {
          return { ...c, tasks: c.tasks.filter((t) => t.id !== taskId) };
        }
        if (c.id === targetColumnId) {
          return { ...c, tasks: [...c.tasks, task] };
        }
        return c;
      });
      onTaskMove?.(taskId, sourceColumn.id, targetColumnId);
      return next;
    });
  };

  return (
    <div className={cn("flex gap-4 overflow-x-auto pb-2", className)}>
      {columns.map((column) => (
        <div
          key={column.id}
          onDragOver={(e) => handleColumnDragOver(e, column.id)}
          onDrop={(e) => handleColumnDrop(e, column.id)}
          onDragLeave={() => setDragOverColumnId(null)}
          className={cn(
            "flex-shrink-0 w-72 rounded-2xl glass-blur-sm glass-surface-dark glass-border p-3 transition-all duration-200",
            dragOverColumnId === column.id && "ring-2 ring-liquid-blue/30"
          )}
        >
          {/* Column header */}
          <div className="flex items-center justify-between mb-3 px-1">
            <div className="flex items-center gap-2">
              <div
                className="h-2 w-2 rounded-full"
                style={{ backgroundColor: column.color || "rgba(255,255,255,0.3)" }}
              />
              <h4 className="text-sm font-semibold text-[var(--lg-text-secondary)]">{column.title}</h4>
              <span className="text-xs text-[var(--lg-text-muted)] bg-[var(--lg-border-subtle)] px-1.5 py-0.5 rounded-md">
                {column.tasks.length}
              </span>
            </div>
            <button className="text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors">
              <MoreHorizontal size={14} />
            </button>
          </div>

          {/* Tasks */}
          <div className="space-y-2">
            {column.tasks.map((task, i) => (
              <motion.div
                key={task.id}
                draggable
                onDragStart={(e) => handleTaskDragStart(e as unknown as React.DragEvent<HTMLDivElement>, task.id)}
                onDragEnd={() => {
                  setDraggedTaskId(null);
                  setDragOverColumnId(null);
                }}
                initial={{ opacity: 0, y: 8 }}
                animate={{ opacity: draggedTaskId === task.id ? 0.4 : 1, y: 0 }}
                transition={{ delay: i * 0.05 }}
                whileHover={{ y: -2 }}
                className={cn(
                  "relative overflow-hidden rounded-xl cursor-grab active:cursor-grabbing",
                  "glass-blur glass-surface-strong glass-border glass-highlight",
                  draggedTaskId === task.id && "opacity-40"
                )}
              >
                {/* Top highlight */}
                <GlassTopHighlight className="inset-x-3 top-0" opacity={0.25} />
                {/* Reflection blob */}
                <div className="pointer-events-none absolute -top-6 -right-6 h-16 w-16 rounded-full glass-reflection blur-2xl" />
                {/* Sheen */}
                <GlassSheen opacity={0.15} />

                <div className="relative p-3">
                  {task.tag && (
                    <span
                      className="inline-block text-[10px] font-medium px-2 py-0.5 rounded-md mb-2"
                      style={{
                        backgroundColor: task.tagColor ? `${task.tagColor}20` : tagFill.style.background,
                        color: task.tagColor || "rgba(255,255,255,0.6)",
                      }}
                    >
                      {task.tag}
                    </span>
                  )}
                  <p className="text-sm font-medium text-[var(--lg-text-secondary)] mb-1">{task.title}</p>
                  {task.description && (
                    <p className="text-xs text-[var(--lg-text-muted)] leading-relaxed">{task.description}</p>
                  )}
                  {task.assignees && task.assignees.length > 0 && (
                    <div className="flex -space-x-1.5 mt-2">
                      {task.assignees.map((a, j) => (
                        <div
                          key={j}
                          className="h-5 w-5 rounded-full bg-gradient-to-br from-white/15 to-white/5 ring-1 ring-[var(--lg-border)] flex items-center justify-center text-[8px] font-bold text-[var(--lg-text-secondary)]"
                        >
                          {a.slice(0, 2).toUpperCase()}
                        </div>
                      ))}
                    </div>
                  )}
                </div>
              </motion.div>
            ))}
          </div>

          {/* Add button */}
          <motion.button
            whileHover={{ scale: 1.01 }}
            whileTap={{ scale: 0.99 }}
            className="flex w-full items-center justify-center gap-1.5 mt-2 py-2 rounded-xl text-xs text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] hover:bg-[var(--lg-border-subtle)] transition-colors"
          >
            <Plus size={12} />
            Add task
          </motion.button>
        </div>
      ))}
    </div>
  );
}

Open in interactive docs

LiquidGlassMenubar

Category: Overlays, Menus & Tooltips

Dropdown menubar for desktop-style menus.

Props

PropTypeRequiredDescription
menus MenuItem[] Yes -
className string No Additional Tailwind CSS classes.

Usage

<LiquidGlassMenubar />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { useGlassOverlayRootStyle, mergeRefs } from "./useLiquidMotion";

interface MenuItem {
  label: string;
  items: {
    label: string;
    shortcut?: string;
    disabled?: boolean;
    separator?: boolean;
    onClick?: () => void;
  }[];
}

interface LiquidGlassMenubarProps {
  menus: MenuItem[];
  className?: string;
}

export function LiquidGlassMenubar({ menus, className }: LiquidGlassMenubarProps) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
  const overlayRef = useGlassOverlayRootStyle(activeIndex !== null);
  const barRef = useRef<HTMLDivElement>(null);
  const dropdownRef = useRef<HTMLDivElement>(null);
  const triggerRefs = useRef<(HTMLButtonElement | null)[]>([]);

  useEffect(() => {
    const handle = (e: MouseEvent) => {
      if (
        barRef.current &&
        !barRef.current.contains(e.target as Node) &&
        dropdownRef.current &&
        !dropdownRef.current.contains(e.target as Node)
      ) {
        setActiveIndex(null);
      }
    };
    document.addEventListener("mousedown", handle);
    return () => document.removeEventListener("mousedown", handle);
  }, []);

  useEffect(() => {
    if (activeIndex === null) {
      setMenuPos(null);
      return;
    }
    const btn = triggerRefs.current[activeIndex];
    if (!btn) return;
    const update = () => {
      const rect = btn.getBoundingClientRect();
      setMenuPos({ left: rect.left, top: rect.bottom + 6 });
    };
    update();
    window.addEventListener("resize", update);
    return () => window.removeEventListener("resize", update);
  }, [activeIndex]);

  const dropdown = (
    <AnimatePresence>
      {activeIndex !== null && menuPos && (
        <motion.div
          ref={mergeRefs(dropdownRef, overlayRef)}
          initial={{ opacity: 0.2, y: 4 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: 4 }}
          transition={{ duration: 0.1 }}
          style={{left: menuPos.left, top: menuPos.top}}
          className="fixed z-[100] min-w-[180px] rounded-xl overflow-hidden glass-blur-xl glass-surface glass-border glass-highlight"
        >
          {/* Top highlight */}
          <GlassTopHighlight className="inset-x-0 top-0" opacity={0.2} />
          <div className="py-1">
            {menus[activeIndex]?.items.map((item, j) =>
              item.separator ? (
                <div key={`sep-${j}`} className="my-1 h-px bg-[var(--lg-border-subtle)] mx-2" />
              ) : (
                <button
                  key={j}
                  onClick={() => {
                    if (!item.disabled) {
                      item.onClick?.();
                      setActiveIndex(null);
                    }
                  }}
                  className={cn(
                    "flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors",
                    item.disabled
                      ? "text-[var(--lg-text-muted)] cursor-not-allowed"
                      : "text-[var(--lg-text-secondary)] hover:bg-[var(--lg-border)] hover:text-[var(--lg-text)]"
                  )}
                >
                  <span>{item.label}</span>
                  {item.shortcut && (
                    <span className="text-[10px] text-[var(--lg-text-muted)] ml-4">
                      {item.shortcut}
                    </span>
                  )}
                </button>
              )
            )}
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );

  return (
    <div
      ref={barRef}
      className={cn(
        "inline-flex items-center gap-0.5 px-2 py-1.5 rounded-xl",
        "glass-blur-sm glass-surface glass-border",
        className
      )}
    >
      {menus.map((menu, i) => (
        <div key={i} className="relative">
          <motion.button
            ref={(el) => { triggerRefs.current[i] = el; }}
            whileHover={{ scale: 1.02 }}
            onClick={() => setActiveIndex(activeIndex === i ? null : i)}
            className={cn(
              "px-3 py-1.5 rounded-lg text-sm font-medium transition-colors",
              activeIndex === i
                ? "bg-[var(--lg-border)] text-[var(--lg-text)]"
                : "text-[var(--lg-text-secondary)] hover:text-[var(--lg-text)] hover:bg-[var(--lg-border-subtle)]"
            )}
          >
            {menu.label}
          </motion.button>
        </div>
      ))}
      {createPortal(dropdown, document.body)}
    </div>
  );
}

Open in interactive docs

LiquidGlassModal

Category: Modals, Drawers & Sheets

Centered modal dialog with glass blur.

Props

PropTypeRequiredDescription
isOpen boolean Yes Controlled open state.
onClose () => void Yes Callback when the component requests to close.
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
title string No Title text.
size "sm" | "md" | "lg" | "xl" No Size preset.

Usage

<LiquidGlassModal />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import type { ReactNode } from "react";
import { X } from "lucide-react";
import { GlassTopHighlight } from "./GlassTopHighlight";
import {
  useLiquidOverlayVariants,
  useLiquidTransition,
  useLiquidTapScale,
  useGlassOverlayRootStyle,
} from "./useLiquidMotion";

interface LiquidGlassModalProps {
  isOpen: boolean;
  onClose: () => void;
  children: ReactNode;
  className?: string;
  title?: string;
  size?: "sm" | "md" | "lg" | "xl";
}

const sizeStyles = {
  sm: "max-w-sm",
  md: "max-w-md",
  lg: "max-w-lg",
  xl: "max-w-xl",
};

export function LiquidGlassModal({
  isOpen,
  onClose,
  children,
  className,
  title,
  size = "md",
}: LiquidGlassModalProps) {
  const overlayVariants = useLiquidOverlayVariants();
  const tapScale = useLiquidTapScale();
  const overlayRef = useGlassOverlayRootStyle(isOpen);
  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          initial={{ opacity: 1 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.2 }}
          ref={overlayRef}
          className="fixed inset-0 z-50 flex items-center justify-center p-4"
          onClick={onClose}
        >
          {/* Backdrop */}
          <div className="glass-backdrop" />

          {/* Modal */}
          <motion.div
            {...overlayVariants}
            transition={useLiquidTransition()}
            onClick={(e) => e.stopPropagation()}
            className={cn(
              "relative w-full",
              "glass-blur-xl glass-surface glass-border glass-highlight-strong",
              "rounded-3xl overflow-hidden",
              sizeStyles[size],
              className
            )}
          >
            {/* Top highlight */}
            <GlassTopHighlight className="inset-x-0 top-0" opacity={0.3} />
            {/* Reflection */}
            <div className="pointer-events-none absolute -top-10 -right-10 h-32 w-32 rounded-full bg-[var(--lg-border-subtle)] blur-2xl" />

            {title && (
              <div className="flex items-center justify-between px-6 pt-6 pb-2">
                <h3 className="text-lg font-semibold text-[var(--lg-text)]">{title}</h3>
                <motion.button
                  whileHover={{ scale: 1.1 }}
                  whileTap={{ scale: tapScale }}
                  onClick={onClose}
                  className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--lg-border-subtle)] text-[var(--lg-text-muted)] hover:bg-[var(--lg-border)] hover:text-[var(--lg-text-secondary)] transition-colors"
                >
                  <X size={16} />
                </motion.button>
              </div>
            )}
            <div className="p-6">{children}</div>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

Open in interactive docs

LiquidGlassMusicPlayer

Category: Media & Content

Glass music player card.

Props

PropTypeRequiredDescription
className string No Additional Tailwind CSS classes.
title string No Title text.
artist string No -
coverUrl string No -
currentTime number No -

Usage

<LiquidGlassMusicPlayer />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { Play, Pause, SkipBack, SkipForward, Volume2, Repeat, Shuffle, Heart } from "lucide-react";
import { useState } from "react";
import { useGlassSurface } from "./useGlassSurface";
import { GlassTopHighlight } from "./GlassTopHighlight";

interface LiquidGlassMusicPlayerProps {
  className?: string;
  title?: string;
  artist?: string;
  coverUrl?: string;
  duration?: number; // seconds
  currentTime?: number;
}

export function LiquidGlassMusicPlayer({
  className,
  title = "Midnight City",
  artist = "M83",
  coverUrl,
  duration = 243,
  currentTime: initialTime = 87,
}: LiquidGlassMusicPlayerProps) {
  const coverDotFill = useGlassSurface({ variant: "fill", opacity: 0.2 });
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(initialTime);
  const [liked, setLiked] = useState(false);
  const [volume, setVolume] = useState(70);

  const formatTime = (s: number) => {
    const m = Math.floor(s / 60);
    const sec = Math.floor(s % 60);
    return `${m}:${sec.toString().padStart(2, "0")}`;
  };

  const progress = (currentTime / duration) * 100;

  return (
    <div
      className={cn(
        "w-full max-w-sm p-5 rounded-3xl",
        "glass-blur-lg glass-surface glass-border glass-highlight-strong",
        className
      )}
    >
      {/* Top highlight */}
      <GlassTopHighlight className="inset-x-0 top-0" opacity={0.25} />

      {/* Cover */}
      <div className="relative mx-auto mb-5 w-48 h-48 rounded-2xl overflow-hidden shadow-2xl shadow-black/30">
        {coverUrl ? (
          <img src={coverUrl} alt={title} className="w-full h-full object-cover" />
        ) : (
          <div className="w-full h-full bg-gradient-to-br from-liquid-purple/40 via-liquid-blue/30 to-liquid-pink/40 flex items-center justify-center">
            <div className="w-20 h-20 rounded-full bg-[var(--lg-border)] flex items-center justify-center">
              <div className="w-8 h-8 rounded-full" style={{ background: coverDotFill.style.background }} />
            </div>
          </div>
        )}
        {/* Glass overlay on cover */}
        <div className="absolute inset-0 ring-1 ring-inset ring-white/10 rounded-2xl" />
      </div>

      {/* Track info */}
      <div className="flex items-start justify-between mb-4">
        <div className="min-w-0">
          <h3 className="text-base font-semibold text-[var(--lg-text)] truncate">{title}</h3>
          <p className="text-sm text-[var(--lg-text-muted)] truncate">{artist}</p>
        </div>
        <motion.button
          whileHover={{ scale: 1.15 }}
          whileTap={{ scale: 0.9 }}
          onClick={() => setLiked(!liked)}
          className={cn(
            "flex-shrink-0 mt-1 transition-colors",
            liked ? "text-liquid-rose" : "text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]"
          )}
        >
          <Heart size={20} className={liked ? "fill-liquid-rose" : ""} />
        </motion.button>
      </div>

      {/* Progress */}
      <div className="mb-4">
        <div
          className="relative h-1 rounded-full bg-[var(--lg-border)] cursor-pointer"
          onClick={(e) => {
            const rect = e.currentTarget.getBoundingClientRect();
            const pct = (e.clientX - rect.left) / rect.width;
            setCurrentTime(Math.floor(pct * duration));
          }}
        >
          <motion.div
            className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-liquid-blue to-liquid-purple"
            style={{ width: `${progress}%` }}
          />
          <div
            className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow-lg"
            style={{ left: `${progress}%`, transform: `translate(-50%, -50%)` }}
          />
        </div>
        <div className="flex justify-between mt-1.5">
          <span className="text-[10px] text-[var(--lg-text-muted)] tabular-nums">{formatTime(currentTime)}</span>
          <span className="text-[10px] text-[var(--lg-text-muted)] tabular-nums">{formatTime(duration)}</span>
        </div>
      </div>

      {/* Controls */}
      <div className="flex items-center justify-center gap-4 mb-4">
        <motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} className="text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors">
          <Shuffle size={16} />
        </motion.button>
        <motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} className="text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors">
          <SkipBack size={22} />
        </motion.button>
        <motion.button
          whileHover={{ scale: 1.1 }}
          whileTap={{ scale: 0.9 }}
          onClick={() => setIsPlaying(!isPlaying)}
          className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--lg-border)] glass-border"
        >
          {isPlaying ? (
            <Pause size={20} className="text-[var(--lg-text)]" />
          ) : (
            <Play size={20} className="text-[var(--lg-text)] ml-0.5" />
          )}
        </motion.button>
        <motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} className="text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors">
          <SkipForward size={22} />
        </motion.button>
        <motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} className="text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors">
          <Repeat size={16} />
        </motion.button>
      </div>

      {/* Volume */}
      <div className="flex items-center gap-2">
        <Volume2 size={14} className="text-[var(--lg-text-muted)] flex-shrink-0" />
        <div
          className="relative flex-1 h-1 rounded-full bg-[var(--lg-border)] cursor-pointer"
          onClick={(e) => {
            const rect = e.currentTarget.getBoundingClientRect();
            const pct = (e.clientX - rect.left) / rect.width;
            setVolume(Math.round(pct * 100));
          }}
        >
          <div
            className="absolute inset-y-0 left-0 rounded-full bg-[var(--lg-border)]"
            style={{ width: `${volume}%` }}
          />
        </div>
      </div>
    </div>
  );
}

Open in interactive docs

LiquidGlassNavigation

Category: Navigation

Horizontal glass navigation bar with animated active indicator.

Props

PropTypeRequiredDescription
items NavItem[] Yes -
className string No Additional Tailwind CSS classes.
logo ReactNode No -
actions ReactNode No -

Usage

<LiquidGlassNavigation />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { useGlassSurface } from "./useGlassSurface";
import { useGlassFluidity } from "./useGlassFluidity";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { GlassSheen } from "./GlassSheen";
import { useLiquidTapScale } from "./useLiquidMotion";

interface NavItem {
  label: string;
  icon?: ReactNode;
  active?: boolean;
  onClick?: () => void;
}

interface LiquidGlassNavigationProps {
  items: NavItem[];
  className?: string;
  logo?: ReactNode;
  actions?: ReactNode;
}

export function LiquidGlassNavigation({
  items,
  className,
  logo,
  actions,
}: LiquidGlassNavigationProps) {
  const tapScale = useLiquidTapScale();
  const fluidity = useGlassFluidity();
  const thumbSurface = useGlassSurface({ variant: "thumb" });

  return (
    <nav
      className={cn(
        "relative flex items-center justify-between px-4 py-3",
        "glass-blur-lg glass-surface glass-border glass-highlight",
        "rounded-2xl",
        className
      )}
    >
      <GlassTopHighlight className="inset-x-0 top-0" opacity={0.2} />

      {logo && <div className="flex-shrink-0 mr-4">{logo}</div>}

      <div className="flex items-center gap-1">
        {items.map((item, i) => (
          <motion.button
            key={i}
            whileHover={{ scale: 1.05 }}
            whileTap={{ scale: tapScale }}
            onClick={item.onClick}
            className={cn(
              "relative flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors",
              item.active
                ? "text-[var(--lg-text)]"
                : "text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]"
            )}
          >
            {item.active && (
              <motion.div
                layoutId="nav-active"
                className="absolute inset-0 rounded-xl glass-blur-lg pointer-events-none overflow-hidden"
                transition={{ type: "spring", stiffness: 300 + fluidity * 3, damping: 30 }}
                style={thumbSurface.style}
              >
                <div className="absolute inset-0 rounded-xl glass-reflection mix-blend-soft-light pointer-events-none" />
                <GlassSheen className="rounded-xl" opacity={0.18} />
                <div className="pointer-events-none absolute inset-x-2 top-0.5 h-px bg-[var(--lg-border)] rounded-full" />
              </motion.div>
            )}
            <span className="relative flex items-center gap-2">
              {item.icon}
              {item.label}
            </span>
          </motion.button>
        ))}
      </div>

      {actions && <div className="flex-shrink-0 ml-4 flex items-center gap-2">{actions}</div>}
    </nav>
  );
}

Open in interactive docs

LiquidGlassNotificationDropdown

Category: Overlays, Menus & Tooltips

Bell dropdown panel portaled to document.body.

Props

PropTypeRequiredDescription
notifications Notification[] No -
className string No Additional Tailwind CSS classes.
onMarkAllRead () => void No -
onClearAll () => void No -
onNotificationClick (id: string) => void No -

Usage

<LiquidGlassNotificationDropdown />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { Bell, Check, Trash2, MessageSquare, Heart, UserPlus, AlertCircle } from "lucide-react";
import { useState, useRef, useEffect, type CSSProperties } from "react";
import { createPortal } from "react-dom";
import { useGlassSurface } from "./useGlassSurface";
import { useGlassOverlayRootStyle, useLiquidTapScale, mergeRefs } from "./useLiquidMotion";

interface Notification {
  id: string;
  type: "message" | "like" | "follow" | "alert";
  title: string;
  description: string;
  time: string;
  read: boolean;
}

interface LiquidGlassNotificationDropdownProps {
  notifications?: Notification[];
  className?: string;
  onMarkAllRead?: () => void;
  onClearAll?: () => void;
  onNotificationClick?: (id: string) => void;
}

const typeConfig = {
  message: { icon: MessageSquare, color: "text-liquid-blue", bg: "bg-liquid-blue/10" },
  like: { icon: Heart, color: "text-liquid-rose", bg: "bg-liquid-rose/10" },
  follow: { icon: UserPlus, color: "text-liquid-emerald", bg: "bg-liquid-emerald/10" },
  alert: { icon: AlertCircle, color: "text-liquid-amber", bg: "bg-liquid-amber/10" },
};

const defaultNotifications: Notification[] = [
  { id: "1", type: "message", title: "New message", description: "Sarah sent you a message", time: "2m ago", read: false },
  { id: "2", type: "like", title: "New like", description: "Alex liked your photo", time: "15m ago", read: false },
  { id: "3", type: "follow", title: "New follower", description: "Jordan started following you", time: "1h ago", read: true },
  { id: "4", type: "alert", title: "System alert", description: "Your storage is 90% full", time: "3h ago", read: true },
];

const POPOVER_WIDTH = 320;

export function LiquidGlassNotificationDropdown({
  notifications = defaultNotifications,
  className,
  onMarkAllRead,
  onClearAll,
  onNotificationClick,
}: LiquidGlassNotificationDropdownProps) {
  const tapScale = useLiquidTapScale();
  const [isOpen, setIsOpen] = useState(false);
  const [position, setPosition] = useState({ left: 0, top: 0 });
  const triggerRef = useRef<HTMLDivElement>(null);
  const popoverRef = useRef<HTMLDivElement>(null);
  const popover = useGlassSurface({ variant: "popover" });
  const topHighlight = useGlassSurface({ variant: "highlight", opacity: 0.25 });
  const overlayRef = useGlassOverlayRootStyle(isOpen);
  const unreadFill = useGlassSurface({ variant: "fill", opacity: 0.06 });
  const hoverFill = useGlassSurface({ variant: "fill", opacity: 0.05 });

  const updatePosition = () => {
    if (!triggerRef.current) return;
    const rect = triggerRef.current.getBoundingClientRect();
    setPosition({
      left: Math.max(8, rect.right - POPOVER_WIDTH),
      top: rect.bottom + 8,
    });
  };

  useEffect(() => {
    const handle = (e: MouseEvent) => {
      if (
        triggerRef.current?.contains(e.target as Node) ||
        popoverRef.current?.contains(e.target as Node)
      ) {
        return;
      }
      setIsOpen(false);
    };
    document.addEventListener("mousedown", handle);
    return () => document.removeEventListener("mousedown", handle);
  }, []);

  const handleToggle = () => {
    if (!isOpen) {
      updatePosition();
    }
    setIsOpen((prev) => !prev);
  };

  const popoverNode = (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          ref={mergeRefs(popoverRef, overlayRef)}
          initial={{ opacity: 0.2, y: 8, scale: 0.96 }}
          animate={{ opacity: 1, y: 0, scale: 1 }}
          exit={{ opacity: 0, y: 8, scale: 0.96 }}
          transition={{ duration: 0.15 }}
          className={cn(
            "fixed rounded-2xl overflow-hidden z-[100]",
            popover.className
          )}
          style={{left: position.left,
            top: position.top,
            width: POPOVER_WIDTH,
            ...popover.style}}
        >
          {/* Reflection blob */}
          <div className="pointer-events-none absolute -top-10 -right-10 h-24 w-24 rounded-full glass-reflection blur-2xl" />
          {/* Top highlight */}
          <div className={topHighlight.className} style={topHighlight.style} />

          {/* Header */}
          <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--lg-border-subtle)]">
            <h3 className="text-sm font-semibold text-[var(--lg-text)]">Notifications</h3>
            <div className="flex items-center gap-1">
              <motion.button
                whileHover={{ scale: 1.1 }}
                whileTap={{ scale: tapScale }}
                onClick={onMarkAllRead}
                className="p-1.5 rounded-lg text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] hover:bg-[var(--lg-border-subtle)] transition-colors"
                title="Mark all read"
              >
                <Check size={14} />
              </motion.button>
              <motion.button
                whileHover={{ scale: 1.1 }}
                whileTap={{ scale: tapScale }}
                onClick={onClearAll}
                className="p-1.5 rounded-lg text-[var(--lg-text-muted)] hover:text-liquid-rose hover:bg-[var(--lg-border-subtle)] transition-colors"
                title="Clear all"
              >
                <Trash2 size={14} />
              </motion.button>
            </div>
          </div>

          {/* List */}
          <div className="max-h-80 overflow-y-auto py-1">
            {notifications.length === 0 ? (
              <div className="px-4 py-8 text-center text-sm text-[var(--lg-text-muted)]">
                No notifications
              </div>
            ) : (
              notifications.map((n) => {
                const config = typeConfig[n.type];
                const Icon = config.icon;
                return (
                  <motion.button
                    key={n.id}
                    whileHover={{ x: 2 }}
                    onClick={() => {
                      onNotificationClick?.(n.id);
                      setIsOpen(false);
                    }}
                    className={cn(
                      "flex w-full items-start gap-3 px-4 py-3 text-left transition-colors",
                      !n.read ? "bg-[var(--item-fill)]" : "hover:bg-[var(--item-hover-fill)]"
                    )}
                    style={{
                      "--item-fill": unreadFill.style.background,
                      "--item-hover-fill": hoverFill.style.background,
                    } as CSSProperties}
                  >
                    <div className={cn("flex h-8 w-8 items-center justify-center rounded-lg flex-shrink-0", config.bg)}>
                      <Icon size={14} className={config.color} />
                    </div>
                    <div className="flex-1 min-w-0">
                      <p className="text-sm font-medium text-[var(--lg-text-secondary)]">{n.title}</p>
                      <p className="text-xs text-[var(--lg-text-muted)] truncate">{n.description}</p>
                      <p className="text-[10px] text-[var(--lg-text-muted)] mt-0.5">{n.time}</p>
                    </div>
                    {!n.read && (
                      <span className="flex-shrink-0 mt-1.5 h-2 w-2 rounded-full bg-liquid-blue" />
                    )}
                  </motion.button>
                );
              })
            )}
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );

  return (
    <div ref={triggerRef} className={cn("relative", className)}>
      <motion.button
        whileHover={{ scale: 1.05 }}
        whileTap={{ scale: tapScale }}
        onClick={handleToggle}
        className="relative flex h-10 w-10 items-center justify-center rounded-xl glass-blur-sm glass-surface glass-border text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors"
      >
        <Bell size={18} />
      </motion.button>
      {createPortal(popoverNode, document.body)}
    </div>
  );
}

Open in interactive docs

LiquidGlassPagination

Category: Data Display

Pagination controls with ellipsis handling.

Props

PropTypeRequiredDescription
currentPage number Yes -
totalPages number Yes -
onChange (page: number) => void Yes Callback when the value changes.
className string No Additional Tailwind CSS classes.
showEdges boolean No -
siblingCount number No -

Usage

<LiquidGlassPagination />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";

interface LiquidGlassPaginationProps {
  currentPage: number;
  totalPages: number;
  onChange: (page: number) => void;
  className?: string;
  showEdges?: boolean;
  siblingCount?: number;
}

export function LiquidGlassPagination({
  currentPage,
  totalPages,
  onChange,
  className,
  showEdges = true,
  siblingCount = 1,
}: LiquidGlassPaginationProps) {
  const getPages = () => {
    const pages: (number | "ellipsis")[] = [];
    const left = Math.max(1, currentPage - siblingCount);
    const right = Math.min(totalPages, currentPage + siblingCount);

    if (showEdges && left > 1) pages.push(1);
    if (showEdges && left > 2) pages.push("ellipsis");
    for (let i = left; i <= right; i++) pages.push(i);
    if (showEdges && right < totalPages - 1) pages.push("ellipsis");
    if (showEdges && right < totalPages) pages.push(totalPages);
    return pages;
  };

  const pages = getPages();

  return (
    <div className={cn("inline-flex items-center gap-1.5", className)}>
      <motion.button
        whileHover={{ scale: 1.05 }}
        whileTap={{ scale: 0.95 }}
        onClick={() => onChange(Math.max(1, currentPage - 1))}
        disabled={currentPage === 1}
        className={cn(
          "flex h-9 w-9 items-center justify-center rounded-xl",
          "glass-blur-sm glass-surface glass-border",
          "text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors",
          currentPage === 1 && "opacity-30 cursor-not-allowed"
        )}
      >
        <ChevronLeft size={16} />
      </motion.button>

      {pages.map((page, i) =>
        page === "ellipsis" ? (
          <span key={`ellipsis-${i}`} className="flex h-9 w-9 items-center justify-center text-[var(--lg-text-muted)]">
            <MoreHorizontal size={16} />
          </span>
        ) : (
          <motion.button
            key={page}
            whileHover={{ scale: 1.05 }}
            whileTap={{ scale: 0.95 }}
            onClick={() => onChange(page)}
            className={cn(
              "flex h-9 w-9 items-center justify-center rounded-xl text-sm font-medium transition-all",
              page === currentPage
                ? "bg-[var(--lg-border)] text-[var(--lg-text)] glass-border"
                : "glass-blur-sm glass-surface glass-border text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]"
            )}
          >
            {page}
          </motion.button>
        )
      )}

      <motion.button
        whileHover={{ scale: 1.05 }}
        whileTap={{ scale: 0.95 }}
        onClick={() => onChange(Math.min(totalPages, currentPage + 1))}
        disabled={currentPage === totalPages}
        className={cn(
          "flex h-9 w-9 items-center justify-center rounded-xl",
          "glass-blur-sm glass-surface glass-border",
          "text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors",
          currentPage === totalPages && "opacity-30 cursor-not-allowed"
        )}
      >
        <ChevronRight size={16} />
      </motion.button>
    </div>
  );
}

Open in interactive docs

LiquidGlassPressSplash

Category: Theme & Glass Primitives

Animated press splash used by buttons and chips.

Props

PropTypeRequiredDescription
size number No Size preset.
tint "light" | "dark" No -
className string No Additional Tailwind CSS classes.
duration number No -

Usage

<LiquidGlassPressSplash />

Source

import { motion } from "framer-motion";

interface LiquidGlassPressSplashProps {
  press: { isPressed: boolean; x: number; y: number };
  size?: number;
  tint?: "light" | "dark";
  className?: string;
  duration?: number;
}

export function LiquidGlassPressSplash({
  press,
  size = 140,
  tint = "light",
  className,
  duration = 0.55,
}: LiquidGlassPressSplashProps) {
  const splashBackground =
    tint === "light"
      ? "radial-gradient(circle at 35% 35%, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0.22) 26%, rgba(255,255,255,0.06) 50%, transparent 72%)"
      : "radial-gradient(circle at 35% 35%, rgba(0,0,0,0.42) 0%, rgba(0,0,0,0.16) 26%, rgba(0,0,0,0.04) 50%, transparent 72%)";

  return (
    <motion.div
      className={`pointer-events-none absolute mix-blend-soft-light ${className || ""}`}
      initial={false}
      animate={{
        opacity: press.isPressed ? [0.52, 0] : 0,
        scale: press.isPressed ? [0, 1.6] : 0,
        borderRadius: press.isPressed
          ? [
              "45% 55% 50% 50% / 55% 45% 50% 50%",
              "55% 45% 52% 48% / 48% 52% 45% 55%",
              "50% 50% 50% 50% / 50% 50% 50% 50%",
            ]
          : "50%",
      }}
      transition={{ duration, ease: [0.25, 0.46, 0.45, 0.94] }}
      style={{
        left: `${press.x}%`,
        top: `${press.y}%`,
        width: size,
        height: size,
        marginLeft: -size / 2,
        marginTop: -size / 2,
        background: splashBackground,
        filter: "blur(1.5px)",
      }}
    />
  );
}

Open in interactive docs

LiquidGlassProgress

Category: Feedback & Status

Progress bar with default, gradient, and segmented variants.

Props

PropTypeRequiredDescription
value number No Current value of the control.
max number No -
className string No Additional Tailwind CSS classes.
size "sm" | "md" | "lg" No Size preset.
variant "default" | "gradient" | "segmented" No Visual variant to use.
showValue boolean No -
animated boolean No -

Usage

<LiquidGlassProgress />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";

interface LiquidGlassProgressProps {
  value?: number;
  max?: number;
  className?: string;
  size?: "sm" | "md" | "lg";
  variant?: "default" | "gradient" | "segmented";
  showValue?: boolean;
  animated?: boolean;
}

const sizeStyles = {
  sm: "h-1.5 rounded-full",
  md: "h-2.5 rounded-full",
  lg: "h-4 rounded-xl",
};

export function LiquidGlassProgress({
  value = 0,
  max = 100,
  className,
  size = "md",
  variant = "default",
  showValue = false,
  animated = true,
}: LiquidGlassProgressProps) {
  const percentage = Math.min(100, Math.max(0, (value / max) * 100));

  if (variant === "segmented") {
    const segments = 10;
    const filledSegments = Math.round((percentage / 100) * segments);

    return (
      <div className={cn("w-full", className)}>
        <div className="flex gap-1">
          {Array.from({ length: segments }).map((_, i) => (
            <motion.div
              key={i}
              initial={false}
              animate={{
                opacity: i < filledSegments ? 1 : 0.2,
                scale: i < filledSegments ? 1 : 0.95,
              }}
              transition={{ duration: 0.3, delay: i * 0.03 }}
              className={cn(
                "flex-1 rounded-full transition-colors",
                sizeStyles[size],
                i < filledSegments
                  ? "bg-gradient-to-r from-liquid-blue to-liquid-purple"
                  : "bg-[var(--lg-border)]"
              )}
            />
          ))}
        </div>
        {showValue && (
          <div className="mt-1.5 text-right text-xs text-[var(--lg-text-muted)]">
            {Math.round(percentage)}%
          </div>
        )}
      </div>
    );
  }

  return (
    <div className={cn("w-full", className)}>
      <div className="flex items-center gap-3">
        <div
          className={cn(
            "relative flex-1 overflow-hidden",
            "glass-blur-sm glass-surface-dark border border-[var(--lg-border-subtle)]",
            sizeStyles[size]
          )}
        >
          <motion.div
            initial={animated ? { width: 0 } : false}
            animate={{ width: `${percentage}%` }}
            transition={{ duration: 0.8, ease: "easeOut" }}
            className={cn(
              "absolute inset-y-0 left-0 rounded-full",
              variant === "gradient"
                ? "bg-gradient-to-r from-liquid-blue via-liquid-purple to-liquid-pink"
                : "bg-gradient-to-r from-liquid-blue/70 to-liquid-purple/70"
            )}
          >
            {/* Shimmer effect */}
            {animated && (
              <div
                className="absolute inset-0 rounded-full"
                style={{
                  background:
                    "linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)",
                  backgroundSize: "200% 100%",
                  animation: "shimmer 2s linear infinite",
                }}
              />
            )}
          </motion.div>
        </div>
        {showValue && (
          <span className="text-xs font-medium text-[var(--lg-text-secondary)] tabular-nums w-10 text-right">
            {Math.round(percentage)}%
          </span>
        )}
      </div>
    </div>
  );
}

Open in interactive docs

LiquidGlassRadioGroup

Category: Inputs, Toggles & Pickers

Radio group with glass selection indicators.

Props

PropTypeRequiredDescription
options RadioOption[] Yes -
value string No Current value of the control.
onChange (value: string) => void No Callback when the value changes.
className string No Additional Tailwind CSS classes.
direction "vertical" | "horizontal" No -
size "sm" | "md" | "lg" No Size preset.

Usage

<LiquidGlassRadioGroup />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { useLiquidTapScale, useLiquidTransition } from "./useLiquidMotion";

interface RadioOption {
  value: string;
  label: string;
  description?: string;
  disabled?: boolean;
}

interface LiquidGlassRadioGroupProps {
  options: RadioOption[];
  value?: string;
  onChange?: (value: string) => void;
  className?: string;
  direction?: "vertical" | "horizontal";
  size?: "sm" | "md" | "lg";
}

const sizeStyles = {
  sm: { outer: "w-4 h-4", inner: "w-2 h-2" },
  md: { outer: "w-5 h-5", inner: "w-2.5 h-2.5" },
  lg: { outer: "w-6 h-6", inner: "w-3 h-3" },
};

export function LiquidGlassRadioGroup({
  options,
  value,
  onChange,
  className,
  direction = "vertical",
  size = "md",
}: LiquidGlassRadioGroupProps) {
  const tapScale = useLiquidTapScale();
  const dotTransition = useLiquidTransition();
  const s = sizeStyles[size];

  return (
    <div
      className={cn(
        "flex",
        direction === "vertical" ? "flex-col gap-3" : "flex-row flex-wrap gap-4",
        className
      )}
    >
      {options.map((option) => {
        const isSelected = value === option.value;
        return (
          <label
            key={option.value}
            className={cn(
              "flex items-start gap-3 cursor-pointer select-none",
              option.disabled && "opacity-40 cursor-not-allowed"
            )}
          >
            <motion.div
              whileTap={option.disabled ? {} : { scale: tapScale }}
              onClick={() => !option.disabled && onChange?.(option.value)}
              className={cn(
                "relative flex items-center justify-center flex-shrink-0 mt-0.5 rounded-full",
                "glass-blur-sm border transition-all duration-200",
                s.outer,
                isSelected
                  ? "bg-liquid-blue/20 border-liquid-blue/50"
                  : "bg-[var(--lg-border-subtle)] border-[var(--lg-border-subtle)] hover:border-[var(--lg-border)]"
              )}
            >
              {/* Top highlight */}
              <GlassTopHighlight className="inset-x-1 top-0.5" opacity={0.2} />
              {isSelected && (
                <motion.div
                  initial={{ scale: 0 }}
                  animate={{ scale: 1 }}
                  transition={dotTransition}
                  className={cn(
                    "rounded-full bg-liquid-blue",
                    s.inner
                  )}
                />
              )}
            </motion.div>
            <div className="flex-1 min-w-0">
              <span
                className={cn(
                  "text-sm font-medium",
                  option.disabled ? "text-[var(--lg-text-muted)]" : "text-[var(--lg-text-secondary)]"
                )}
              >
                {option.label}
              </span>
              {option.description && (
                <p className="text-xs text-[var(--lg-text-muted)] mt-0.5">{option.description}</p>
              )}
            </div>
          </label>
        );
      })}
    </div>
  );
}

Open in interactive docs

LiquidGlassRating

Category: Feedback & Status

Star rating with half-star support.

Props

PropTypeRequiredDescription
value number No Current value of the control.
max number No -
onChange (value: number) => void No Callback when the value changes.
className string No Additional Tailwind CSS classes.
size "sm" | "md" | "lg" No Size preset.
readonly boolean No -
showValue boolean No -

Usage

<LiquidGlassRating />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { Star } from "lucide-react";
import { useState } from "react";

interface LiquidGlassRatingProps {
  value?: number;
  max?: number;
  onChange?: (value: number) => void;
  className?: string;
  size?: "sm" | "md" | "lg";
  readonly?: boolean;
  showValue?: boolean;
}

const sizeMap = {
  sm: 14,
  md: 20,
  lg: 28,
};

export function LiquidGlassRating({
  value = 0,
  max = 5,
  onChange,
  className,
  size = "md",
  readonly = false,
  showValue = false,
}: LiquidGlassRatingProps) {
  const [hoverValue, setHoverValue] = useState(0);
  const displayValue = hoverValue || value;
  const s = sizeMap[size];

  return (
    <div className={cn("inline-flex items-center gap-1", className)}>
      <div className="flex items-center gap-0.5">
        {Array.from({ length: max }).map((_, i) => {
          const starValue = i + 1;
          const filled = starValue <= displayValue;
          const halfFilled = !filled && starValue - 0.5 <= displayValue;

          return (
            <motion.button
              key={i}
              whileHover={readonly ? {} : { scale: 1.2 }}
              whileTap={readonly ? {} : { scale: 0.9 }}
              onMouseEnter={() => !readonly && setHoverValue(starValue)}
              onMouseLeave={() => !readonly && setHoverValue(0)}
              onClick={() => !readonly && onChange?.(starValue)}
              className={cn(
                "relative transition-colors",
                readonly ? "cursor-default" : "cursor-pointer"
              )}
            >
              <Star
                size={s}
                className={cn(
                  "transition-colors",
                  filled
                    ? "text-liquid-amber fill-liquid-amber"
                    : halfFilled
                    ? "text-liquid-amber"
                    : "text-[var(--lg-text-muted)]"
                )}
              />
              {halfFilled && (
                <div className="absolute inset-0 overflow-hidden" style={{ width: "50%" }}>
                  <Star size={s} className="text-liquid-amber fill-liquid-amber" />
                </div>
              )}
            </motion.button>
          );
        })}
      </div>
      {showValue && (
        <span className="ml-1.5 text-sm font-medium text-[var(--lg-text-muted)] tabular-nums">
          {value.toFixed(1)}
        </span>
      )}
    </div>
  );
}

Open in interactive docs

LiquidGlassResizable

Category: Layout & Surfaces

Resizable panel with drag handle.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
defaultWidth number No -
defaultHeight number No -
minWidth number No -
minHeight number No -
direction "horizontal" | "vertical" | "both" No -

Usage

<LiquidGlassResizable />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { useState, useRef, useCallback, type ReactNode } from "react";

interface LiquidGlassResizableProps {
  children: ReactNode;
  className?: string;
  defaultWidth?: number;
  defaultHeight?: number;
  minWidth?: number;
  minHeight?: number;
  direction?: "horizontal" | "vertical" | "both";
}

export function LiquidGlassResizable({
  children,
  className,
  defaultWidth = 300,
  defaultHeight = 200,
  minWidth = 150,
  minHeight = 100,
  direction = "both",
}: LiquidGlassResizableProps) {
  const [size, setSize] = useState({ width: defaultWidth, height: defaultHeight });
  const [isResizing, setIsResizing] = useState(false);
  const startPos = useRef({ x: 0, y: 0 });
  const startSize = useRef({ width: 0, height: 0 });

  const startResize = useCallback(
    (e: React.MouseEvent) => {
      e.preventDefault();
      setIsResizing(true);
      startPos.current = { x: e.clientX, y: e.clientY };
      startSize.current = { ...size };

      const handleMove = (ev: MouseEvent) => {
        const dx = ev.clientX - startPos.current.x;
        const dy = ev.clientY - startPos.current.y;
        setSize({
          width: Math.max(minWidth, startSize.current.width + dx),
          height: Math.max(minHeight, startSize.current.height + dy),
        });
      };

      const handleUp = () => {
        setIsResizing(false);
        window.removeEventListener("mousemove", handleMove);
        window.removeEventListener("mouseup", handleUp);
      };

      window.addEventListener("mousemove", handleMove);
      window.addEventListener("mouseup", handleUp);
    },
    [size, minWidth, minHeight]
  );

  return (
    <motion.div
      className={cn(
        "relative rounded-2xl overflow-hidden",
        "glass-blur-sm glass-surface glass-border",
        className
      )}
      style={{
        width: direction !== "vertical" ? size.width : undefined,
        height: direction !== "horizontal" ? size.height : undefined,
      }}
      animate={{ cursor: isResizing ? "nwse-resize" : "default" }}
    >
      <div className="p-4 overflow-auto h-full">{children}</div>

      {/* Resize handle */}
      {(direction === "both" || direction === "horizontal") && (
        <div
          onMouseDown={startResize}
          className={cn(
            "absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize",
            "flex items-end justify-end p-0.5"
          )}
        >
          <svg width="8" height="8" viewBox="0 0 8 8" className="text-[var(--lg-text-muted)]">
            <path d="M0 8L8 0M2 8L8 2M4 8L8 4" stroke="currentColor" strokeWidth="1" fill="none" />
          </svg>
        </div>
      )}
    </motion.div>
  );
}

Open in interactive docs

LiquidGlassScrollArea

Category: Layout & Surfaces

Custom scrollbar scroll area.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
maxHeight string No -
showScrollbar boolean No -

Usage

<LiquidGlassScrollArea />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { useState, useRef, useEffect, type ReactNode } from "react";

interface LiquidGlassScrollAreaProps {
  children: ReactNode;
  className?: string;
  maxHeight?: string;
  showScrollbar?: boolean;
}

export function LiquidGlassScrollArea({
  children,
  className,
  maxHeight = "300px",
  showScrollbar = true,
}: LiquidGlassScrollAreaProps) {
  const [scrollProgress, setScrollProgress] = useState(0);
  const [thumbHeight, setThumbHeight] = useState(30);
  const contentRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = contentRef.current;
    if (!el) return;
    const update = () => {
      const { scrollTop, scrollHeight, clientHeight } = el;
      const progress = scrollHeight > clientHeight ? scrollTop / (scrollHeight - clientHeight) : 0;
      const thumb = Math.max(30, (clientHeight / scrollHeight) * clientHeight);
      setScrollProgress(progress);
      setThumbHeight(thumb);
    };
    el.addEventListener("scroll", update);
    update();
    return () => el.removeEventListener("scroll", update);
  }, []);

  return (
    <div className={cn("relative overflow-hidden", className)}>
      <div
        ref={contentRef}
        className={cn(
          "overflow-y-auto overflow-x-hidden pr-2",
          showScrollbar && "scrollbar-thin"
        )}
        style={{ maxHeight }}
      >
        {children}
      </div>
      {showScrollbar && (
        <div className="absolute right-1 top-2 bottom-2 w-1 rounded-full bg-[var(--lg-border-subtle)]">
          <motion.div
            className="w-full rounded-full bg-[var(--lg-border)] hover:bg-[var(--lg-text-muted)] transition-colors"
            style={{
              height: thumbHeight,
              y: scrollProgress * (parseInt(maxHeight) - thumbHeight - 16),
            }}
          />
        </div>
      )}
    </div>
  );
}

Open in interactive docs

LiquidGlassSelect

Category: Inputs, Toggles & Pickers

Dropdown select portaled to document.body for working blur.

Props

PropTypeRequiredDescription
options SelectOption[] Yes -
value string No Current value of the control.
onChange (value: string) => void No Callback when the value changes.
placeholder string No Placeholder text.
className string No Additional Tailwind CSS classes.
label string No Label text.
disabled boolean No Whether the component is disabled.

Usage

<LiquidGlassSelect />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useRef, useEffect, type CSSProperties } from "react";
import { ChevronDown, Check } from "lucide-react";
import { createPortal } from "react-dom";
import { useGlassSurface } from "./useGlassSurface";
import { useGlassOverlayRootStyle, useLiquidTapScale, mergeRefs } from "./useLiquidMotion";

interface SelectOption {
  value: string;
  label: string;
  icon?: React.ReactNode;
}

interface LiquidGlassSelectProps {
  options: SelectOption[];
  value?: string;
  onChange?: (value: string) => void;
  placeholder?: string;
  className?: string;
  label?: string;
  disabled?: boolean;
}

export function LiquidGlassSelect({
  options,
  value,
  onChange,
  placeholder = "Select an option",
  className,
  label,
  disabled,
}: LiquidGlassSelectProps) {
  const tapScale = useLiquidTapScale();
  const [isOpen, setIsOpen] = useState(false);
  const [position, setPosition] = useState({ left: 0, top: 0, width: 0 });
  const triggerRef = useRef<HTMLDivElement>(null);
  const popoverRef = useRef<HTMLDivElement>(null);
  const selected = options.find((o) => o.value === value);

  const sheen = useGlassSurface({ variant: "sheen", opacity: 0.12 });
  const topHighlight = useGlassSurface({ variant: "highlight", opacity: 0.30 });
  const dropdownHighlight = useGlassSurface({ variant: "highlight", opacity: 0.20 });
  const overlayRef = useGlassOverlayRootStyle(isOpen);
  const selectedFill = useGlassSurface({ variant: "fill", opacity: 0.10 });
  const hoverFill = useGlassSurface({ variant: "fill", opacity: 0.06 });

  const updatePosition = () => {
    if (!triggerRef.current) return;
    const rect = triggerRef.current.getBoundingClientRect();
    setPosition({
      left: rect.left,
      top: rect.bottom + 8,
      width: rect.width,
    });
  };

  useEffect(() => {
    const handleClick = (e: MouseEvent) => {
      if (
        triggerRef.current?.contains(e.target as Node) ||
        popoverRef.current?.contains(e.target as Node)
      ) {
        return;
      }
      setIsOpen(false);
    };
    document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, []);

  const handleToggle = () => {
    if (!isOpen) {
      updatePosition();
    }
    setIsOpen((prev) => !prev);
  };

  const popoverNode = (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          ref={mergeRefs(popoverRef, overlayRef)}
          initial={{ opacity: 0.2, y: -8, scale: 0.96 }}
          animate={{ opacity: 1, y: 0, scale: 1 }}
          exit={{ opacity: 0, y: -8, scale: 0.96 }}
          transition={{ duration: 0.15 }}
          className={cn(
            "fixed z-[100] overflow-hidden",
            "glass-blur-xl glass-surface glass-border glass-highlight-strong",
            "rounded-2xl py-1"
          )}
          style={{left: position.left,
            top: position.top,
            width: position.width}}
        >
          {/* Top highlight */}
          <div className={dropdownHighlight.className} style={dropdownHighlight.style} />
          {options.map((option) => (
            <motion.button
              key={option.value}
              whileHover={{ x: 2 }}
              onClick={() => {
                onChange?.(option.value);
                setIsOpen(false);
              }}
              className={cn(
                "flex w-full items-center gap-2 px-4 py-2.5 text-sm transition-colors",
                value === option.value
                  ? "text-[var(--lg-text)] bg-[var(--selected-fill)]"
                  : "text-[var(--lg-text-secondary)] hover:text-[var(--lg-text)] hover:bg-[var(--hover-fill)]"
              )}
              style={{
                "--selected-fill": selectedFill.style.background,
                "--hover-fill": hoverFill.style.background,
              } as CSSProperties}
            >
              {option.icon}
              <span className="flex-1 text-left">{option.label}</span>
              {value === option.value && (
                <Check size={14} className="text-liquid-blue" />
              )}
            </motion.button>
          ))}
        </motion.div>
      )}
    </AnimatePresence>
  );

  return (
    <div ref={triggerRef} className={cn("relative w-full", className)}>
      {label && (
        <label className="mb-1.5 block text-sm font-medium text-[var(--lg-text-secondary)]">
          {label}
        </label>
      )}
      <motion.button
        whileTap={{ scale: disabled ? 1 : tapScale }}
        onClick={handleToggle}
        className={cn(
          "relative flex w-full items-center justify-between gap-3 overflow-hidden",
          "glass-blur glass-surface-strong glass-border glass-highlight",
          "px-4 py-3 rounded-2xl text-left transition-all duration-200",
          "focus:ring-2 focus:ring-white/20 focus:border-[var(--lg-border)]",
          disabled && "opacity-50 cursor-not-allowed"
        )}
      >
        {/* Top highlight */}
        <div className={cn(topHighlight.className, "inset-x-4")} style={topHighlight.style} />
        {/* Reflection blob */}
        <div className="pointer-events-none absolute -top-6 -right-6 h-16 w-16 rounded-full glass-reflection blur-2xl" />
        {/* Sheen */}
        <div className={sheen.className} style={sheen.style} />

        <div className="relative z-10 flex items-center gap-2 min-w-0">
          {selected?.icon}
          <span className={cn("truncate", !selected && "text-[var(--lg-text-muted)]")}>
            {selected?.label || placeholder}
          </span>
        </div>
        <motion.div
          animate={{ rotate: isOpen ? 180 : 0 }}
          transition={{ duration: 0.2 }}
          className="relative z-10 flex-shrink-0 text-[var(--lg-text-muted)]"
        >
          <ChevronDown size={16} />
        </motion.div>
      </motion.button>
      {createPortal(popoverNode, document.body)}
    </div>
  );
}

Open in interactive docs

LiquidGlassSheet

Category: Modals, Drawers & Sheets

Bottom sheet with default, compact, full, inset, and detached variants.

Props

PropTypeRequiredDescription
isOpen boolean Yes Controlled open state.
onClose () => void Yes Callback when the component requests to close.
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
title string No Title text.
maxHeight string No -
variant "default" | "compact" | "full" | "inset" | "detached" No Visual variant to use.

Usage

<LiquidGlassSheet />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import type { ReactNode } from "react";
import {
  useLiquidSlideVariants,
  useLiquidTransition,
  useGlassOverlayRootStyle,
} from "./useLiquidMotion";

interface LiquidGlassSheetProps {
  isOpen: boolean;
  onClose: () => void;
  children: ReactNode;
  className?: string;
  title?: string;
  maxHeight?: string;
  variant?: "default" | "compact" | "full" | "inset" | "detached";
}

export function LiquidGlassSheet({
  isOpen,
  onClose,
  children,
  className,
  title,
  maxHeight = "70vh",
  variant = "default",
}: LiquidGlassSheetProps) {
  const isFullScreen = variant === "full";
  const isDetached = variant === "detached";
  const isInset = variant === "inset";
  const isCompact = variant === "compact";
  const slideVariants = useLiquidSlideVariants("bottom", { stiff: true });
  const transition = useLiquidTransition({ stiff: true });
  const overlayRef = useGlassOverlayRootStyle(isOpen);

  return (
    <AnimatePresence>
      {isOpen && (
        <div
          ref={overlayRef}
          className={cn(
          "fixed inset-0 z-50 flex items-end justify-center",
          isDetached && "p-4",
          isInset && "p-3"
        )}>
          {/* Backdrop */}
          <motion.div
            initial={{ opacity: 0.2 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
            onClick={onClose}
            className="glass-backdrop-overlay"
          />
          {/* Sheet */}
          <motion.div
            {...slideVariants}
            transition={transition}
            className={cn(
              "relative w-full overflow-hidden",
              "glass-blur-xl glass-surface-strong glass-border glass-highlight-strong",
              isFullScreen ? "h-full rounded-none" :
              isDetached ? "max-w-lg mx-auto rounded-3xl" :
              isInset ? "max-w-lg mx-auto rounded-3xl" :
              isCompact ? "max-w-lg mx-auto rounded-t-3xl" :
              "max-w-lg mx-auto rounded-t-3xl",
              className
            )}
            style={isFullScreen ? undefined : { maxHeight }}
          >
            {/* Handle bar */}
            {!isFullScreen && (
              <div className="flex justify-center pt-3 pb-1">
                <div className="w-10 h-1 rounded-full bg-[var(--lg-border)]" />
              </div>
            )}
            {/* Top highlight */}
            <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
            <div className="pointer-events-none absolute -top-10 -right-10 h-24 w-24 rounded-full bg-[var(--lg-reflection)] blur-2xl" />

            {title && (
              <div className="px-6 pt-2 pb-3">
                <h3 className="text-lg font-semibold text-[var(--lg-text)] text-center">{title}</h3>
              </div>
            )}
            <div className={cn(
              "overflow-y-auto",
              isCompact ? "px-6 pb-6" : "px-6 pb-8"
            )}>{children}</div>
          </motion.div>
        </div>
      )}
    </AnimatePresence>
  );
}

Open in interactive docs

LiquidGlassSkeleton

Category: Feedback & Status

Loading placeholder with shimmer animation.

Props

PropTypeRequiredDescription
className string No Additional Tailwind CSS classes.
variant "text" | "circular" | "rectangular" | "rounded" No Visual variant to use.
width string | number No -
height string | number No -
lines number No -
animate boolean No -

Usage

<LiquidGlassSkeleton />

Source

import { cn } from "../../utils/cn";
import { useGlassSurface } from "./useGlassSurface";

interface LiquidGlassSkeletonProps {
  className?: string;
  variant?: "text" | "circular" | "rectangular" | "rounded";
  width?: string | number;
  height?: string | number;
  lines?: number;
  animate?: boolean;
}

export function LiquidGlassSkeleton({
  className,
  variant = "text",
  width,
  height,
  lines = 1,
  animate = true,
}: LiquidGlassSkeletonProps) {
  const fill = useGlassSurface({ variant: "fill", opacity: 0.05 });
  const shimmerFill = useGlassSurface({ variant: "fill", opacity: 0.05 });
  const baseClasses = cn(
    "",
    animate && "animate-pulse",
    variant === "circular" && "rounded-full",
    variant === "rectangular" && "rounded-none",
    variant === "rounded" && "rounded-xl",
    variant === "text" && "rounded-md",
    className
  );

  const style: React.CSSProperties = {
    width: width,
    height: height,
    background: fill.style.background,
  };

  if (lines > 1 && variant === "text") {
    return (
      <div className="space-y-2">
        {Array.from({ length: lines }).map((_, i) => (
          <div
            key={i}
            className={cn(baseClasses, i === lines - 1 && "w-3/4")}
            style={{
              ...style,
              width: i === lines - 1 ? "75%" : width,
              height: height || 12,
            }}
          />
        ))}
      </div>
    );
  }

  return (
    <div
      className={cn(baseClasses, "relative overflow-hidden")}
      style={{
        ...style,
        height: height || (variant === "text" ? 12 : undefined),
        width: width || (variant === "text" ? "100%" : undefined),
      }}
    >
      {animate && (
        <div
          className="absolute inset-0"
          style={{
            background: `linear-gradient(90deg, transparent, ${shimmerFill.style.background}, transparent)`,
            backgroundSize: "200% 100%",
            animation: "shimmer 1.5s linear infinite",
          }}
        />
      )}
    </div>
  );
}

Open in interactive docs

LiquidGlassSlider

Category: Inputs, Toggles & Pickers

Range slider with a stretching liquid-glass thumb.

Props

PropTypeRequiredDescription
value number No Current value of the control.
min number No -
max number No -
step number No -
onChange (value: number) => void No Callback when the value changes.
className string No Additional Tailwind CSS classes.
showValue boolean No -
disabled boolean No Whether the component is disabled.
label string No Label text.
valueFormatter (value: number) => string No -

Usage

<LiquidGlassSlider />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import {
  useState,
  useRef,
  useCallback,
  useMemo,
} from "react";
import { useTheme } from "./ThemeProvider";
import { useGlassSurface } from "./useGlassSurface";
import { useGlassFluidity } from "./useGlassFluidity";
import { GlassSheen } from "./GlassSheen";

interface LiquidGlassSliderProps {
  value?: number;
  min?: number;
  max?: number;
  step?: number;
  onChange?: (value: number) => void;
  className?: string;
  showValue?: boolean;
  disabled?: boolean;
  label?: string;
  valueFormatter?: (value: number) => string;
}

export function LiquidGlassSlider({
  value = 50,
  min = 0,
  max = 100,
  step = 1,
  onChange,
  className,
  showValue = true,
  disabled,
  label,
  valueFormatter,
}: LiquidGlassSliderProps) {
  const [isDragging, setIsDragging] = useState(false);
  const trackRef = useRef<HTMLDivElement>(null);
  const { isDark } = useTheme();
  const fluidity = useGlassFluidity();
  const thumbSurface = useGlassSurface({ variant: "thumb" });
  const trackFillSurface = useGlassSurface({ variant: "track-active", tint: "#3b82f6", activeTint: "#8b5cf6" });

  const percentage = ((value - min) / (max - min)) * 100;

  // Snappy spring tuned by the global fluidity control.
  const spring = useMemo(() => {
    const stiffness = 720 - fluidity * 3.6;
    const damping = 46 - fluidity * 0.2;
    return { stiffness, damping };
  }, [fluidity]);

  // iOS 26 liquid-glass thumb: a stadium/lozenge that stretches while dragging.
  const thumb = 20;
  const widthRatio = 1.4;
  const idleWidth = thumb * widthRatio;

  const thumbWidth = isDragging ? idleWidth * 1.65 : idleWidth;
  const scaleY = isDragging ? 1.18 : 1;

  const handleMove = useCallback(
    (clientX: number) => {
      if (!trackRef.current || disabled) return;
      const rect = trackRef.current.getBoundingClientRect();
      const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
      const pct = x / rect.width;
      const raw = min + pct * (max - min);
      const stepped = Math.round(raw / step) * step;
      onChange?.(Math.max(min, Math.min(max, stepped)));
    },
    [min, max, step, onChange, disabled]
  );

  const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
    if (disabled) return;
    setIsDragging(true);
    handleMove(e.clientX);

    const handleMouseMove = (ev: MouseEvent) => handleMove(ev.clientX);
    const handleMouseUp = () => {
      setIsDragging(false);
      window.removeEventListener("mousemove", handleMouseMove);
      window.removeEventListener("mouseup", handleMouseUp);
    };
    window.addEventListener("mousemove", handleMouseMove);
    window.addEventListener("mouseup", handleMouseUp);
  };

  return (
    <div className={cn("w-full", className)}>
      <div className="flex items-center justify-between mb-2">
        {label ? (
          <>
            <span className="text-xs font-medium text-[var(--lg-text-secondary)]">
              {label}
            </span>
            <span className="text-xs tabular-nums text-[var(--lg-text-muted)]">
              {valueFormatter ? valueFormatter(value) : value}
            </span>
          </>
        ) : (
          <>
            {showValue && (
              <span
                className={cn(
                  "text-xs font-medium",
                  isDark ? "text-[var(--lg-text-muted)]" : "text-black/40"
                )}
              >
                {min}
              </span>
            )}
            {showValue && (
              <motion.span
                key={value}
                initial={{ scale: 1.2 }}
                animate={{ scale: 1 }}
                className={cn(
                  "text-xs font-semibold tabular-nums",
                  isDark ? "text-[var(--lg-text-secondary)]" : "text-black/70"
                )}
              >
                {value}
              </motion.span>
            )}
          </>
        )}
      </div>
      <div
        ref={trackRef}
        onMouseDown={handleMouseDown}
        className={cn(
          "relative h-2 rounded-full cursor-pointer overflow-visible",
          "glass-blur-sm glass-surface-dark border",
          isDark ? "border-[var(--lg-border-subtle)]" : "border-black/5",
          disabled && "cursor-not-allowed opacity-50"
        )}
      >
        {/* Filled track */}
        <motion.div
          className="absolute inset-y-0 left-0 rounded-full"
          style={{ width: `${percentage}%`, background: trackFillSurface.style.background }}
          animate={{ width: `${percentage}%` }}
          transition={{ type: "spring", stiffness: 300, damping: 30 }}
        />

        {/* Thumb — liquid-glass stadium/lozenge that stretches on drag. */}
        <motion.div
          initial={{ width: idleWidth, scaleY: 1 }}
          animate={{
            left: `${percentage}%`,
            width: thumbWidth,
            scaleY,
          }}
          transition={{
            left: {
              type: "spring",
              stiffness: spring.stiffness,
              damping: spring.damping,
              mass: 0.45,
            },
            width: {
              type: "spring",
              stiffness: spring.stiffness,
              damping: spring.damping,
              mass: 0.45,
            },
            scaleY: {
              type: "spring",
              stiffness: spring.stiffness,
              damping: spring.damping,
              mass: 0.45,
            },
          }}
          className={cn(
            "absolute top-1/2 -translate-y-1/2 -translate-x-1/2 rounded-full z-10 pointer-events-none",
            thumbSurface.className
          )}
          style={{
            left: `${percentage}%`,
            height: thumb,
            ...thumbSurface.style,
          }}
        >
          {/* Reflection response */}
          <div className="absolute inset-0 rounded-full glass-reflection mix-blend-soft-light pointer-events-none" />

          {/* Subtle specular sheen */}
          <GlassSheen className="rounded-full" opacity={0.18} />
        </motion.div>
      </div>
      {!label && showValue && (
        <span
          className={cn(
            "block text-right text-xs font-medium mt-1",
            isDark ? "text-[var(--lg-text-muted)]" : "text-black/40"
          )}
        >
          {max}
        </span>
      )}
    </div>
  );
}

Open in interactive docs

LiquidGlassStatCard

Category: Data Display

Grid of stat cards with change indicators.

Props

PropTypeRequiredDescription
stats StatData[] Yes -
className string No Additional Tailwind CSS classes.
columns number No -

Usage

<LiquidGlassStatCard />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { GlassTopHighlight } from "./GlassTopHighlight";

interface StatData {
  label: string;
  value: string | number;
  change?: number;
  changeLabel?: string;
  icon?: React.ReactNode;
  iconColor?: string;
  iconBg?: string;
}

interface LiquidGlassStatCardProps {
  stats: StatData[];
  className?: string;
  columns?: number;
}

export function LiquidGlassStatCard({
  stats,
  className,
  columns = 4,
}: LiquidGlassStatCardProps) {
  const colClass =
    columns === 2
      ? "grid-cols-2"
      : columns === 3
      ? "grid-cols-3"
      : "grid-cols-2 md:grid-cols-4";

  return (
    <div className={cn("grid gap-4", colClass, className)}>
      {stats.map((stat, i) => {
        const isPositive = stat.change && stat.change > 0;
        const isNegative = stat.change && stat.change < 0;
        const TrendIcon = isPositive ? TrendingUp : isNegative ? TrendingDown : Minus;
        const trendColor = isPositive
          ? "text-liquid-emerald"
          : isNegative
          ? "text-liquid-rose"
          : "text-[var(--lg-text-muted)]";

        return (
          <motion.div
            key={i}
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ delay: i * 0.08 }}
            className={cn(
              "relative p-4 rounded-2xl overflow-hidden",
              "glass-blur-sm glass-surface glass-border glass-highlight"
            )}
          >
            {/* Top highlight */}
            <GlassTopHighlight className="inset-x-0 top-0" opacity={0.15} />

            <div className="flex items-start justify-between mb-3">
              {stat.icon && (
                <div
                  className={cn(
                    "flex h-9 w-9 items-center justify-center rounded-xl",
                    stat.iconBg || "bg-[var(--lg-border-subtle)]"
                  )}
                >
                  <span className={stat.iconColor || "text-[var(--lg-text-muted)]"}>{stat.icon}</span>
                </div>
              )}
              {stat.change !== undefined && (
                <div className={cn("flex items-center gap-0.5 text-xs font-medium", trendColor)}>
                  <TrendIcon size={12} />
                  <span>{Math.abs(stat.change)}%</span>
                </div>
              )}
            </div>

            <p className="text-2xl font-bold text-[var(--lg-text)]">{stat.value}</p>
            <p className="text-xs text-[var(--lg-text-muted)] mt-1">{stat.label}</p>
            {stat.changeLabel && (
              <p className="text-[10px] text-[var(--lg-text-muted)] mt-0.5">{stat.changeLabel}</p>
            )}
          </motion.div>
        );
      })}
    </div>
  );
}

Open in interactive docs

LiquidGlassStepper

Category: Data Display

Horizontal or vertical stepper.

Props

PropTypeRequiredDescription
steps Step[] Yes -
currentStep number Yes -
className string No Additional Tailwind CSS classes.
orientation "horizontal" | "vertical" No -

Usage

<LiquidGlassStepper />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { Check } from "lucide-react";
import { GlassTopHighlight } from "./GlassTopHighlight";

interface Step {
  label: string;
  description?: string;
  icon?: React.ReactNode;
}

interface LiquidGlassStepperProps {
  steps: Step[];
  currentStep: number;
  className?: string;
  orientation?: "horizontal" | "vertical";
}

export function LiquidGlassStepper({
  steps,
  currentStep,
  className,
  orientation = "horizontal",
}: LiquidGlassStepperProps) {
  const isHorizontal = orientation === "horizontal";

  return (
    <div
      className={cn(
        isHorizontal ? "flex items-start" : "flex flex-col",
        className
      )}
    >
      {steps.map((step, i) => {
        const isCompleted = i < currentStep;
        const isCurrent = i === currentStep;

        return (
          <div
            key={i}
            className={cn(
              "relative flex",
              isHorizontal ? "flex-1 flex-col items-center" : "flex-row items-start gap-4 flex-1"
            )}
          >
            {/* Connector line */}
            {i < steps.length - 1 && (
              <div
                className={cn(
                  "absolute bg-[var(--lg-border)]",
                  isHorizontal
                    ? "top-4 left-1/2 right-0 h-px"
                    : "top-10 left-5 w-px bottom-0"
                )}
              >
                <motion.div
                  initial={false}
                  animate={{
                    scaleX: isHorizontal ? (isCompleted ? 1 : 0) : undefined,
                    scaleY: !isHorizontal ? (isCompleted ? 1 : 0) : undefined,
                  }}
                  transition={{ duration: 0.4, ease: "easeOut" }}
                  className={cn(
                    "absolute bg-liquid-blue/50 origin-left",
                    isHorizontal ? "inset-y-0 left-0" : "inset-x-0 top-0"
                  )}
                />
              </div>
            )}

            {/* Step circle */}
            <motion.div
              animate={{
                scale: isCurrent ? 1.1 : 1,
              }}
              className={cn(
                "relative z-10 flex items-center justify-center rounded-full",
                "glass-blur-sm border transition-all duration-300",
                isCompleted
                  ? "bg-liquid-blue/20 border-liquid-blue/40 text-liquid-blue"
                  : isCurrent
                  ? "bg-[var(--lg-border)] border-[var(--lg-border)] text-[var(--lg-text)]"
                  : "bg-[var(--lg-border-subtle)] border-[var(--lg-border-subtle)] text-[var(--lg-text-muted)]",
                isHorizontal ? "w-8 h-8" : "w-10 h-10 flex-shrink-0"
              )}
            >
              {/* Top highlight */}
              <GlassTopHighlight className="inset-x-2 top-0.5" opacity={0.2} />
              {isCompleted ? (
                <Check size={isHorizontal ? 14 : 16} strokeWidth={3} />
              ) : (
                step.icon || <span className="text-xs font-semibold">{i + 1}</span>
              )}
            </motion.div>

            {/* Labels */}
            <div
              className={cn(
                "mt-2 text-center",
                !isHorizontal && "mt-1 text-left flex-1"
              )}
            >
              <p
                className={cn(
                  "text-sm font-medium",
                  isCompleted || isCurrent ? "text-[var(--lg-text-secondary)]" : "text-[var(--lg-text-muted)]"
                )}
              >
                {step.label}
              </p>
              {step.description && (
                <p className="text-xs text-[var(--lg-text-muted)] mt-0.5">{step.description}</p>
              )}
            </div>
          </div>
        );
      })}
    </div>
  );
}

Open in interactive docs

LiquidGlassSurface

Category: Layout & Surfaces

Liquid Glass Surface component.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
className string No Additional Tailwind CSS classes.
borderRadius number No -

Usage

<LiquidGlassSurface />

Source

import { useRef, type ReactNode } from "react";
import { motion } from "framer-motion";
import { cn } from "../../utils/cn";
import { useGlassSurface } from "./useGlassSurface";

interface LiquidGlassSurfaceProps {
  children: ReactNode;
  className?: string;
  borderRadius?: number;
}

export function LiquidGlassSurface({
  children,
  className,
  borderRadius = 24,
}: LiquidGlassSurfaceProps) {
  const ref = useRef<HTMLDivElement>(null);

  // surface-lg uses the full kube filter in liquid-glass mode, so the Blur
  // slider and the shared transparency system apply consistently.
  const surface = useGlassSurface({ variant: "surface-lg" });

  return (
    <motion.div
      ref={ref}
      initial={{ opacity: 0, scale: 0.96 }}
      animate={{ opacity: 1, scale: 1 }}
      transition={{ duration: 0.6, delay: 0.1 }}
      className={cn(surface.className, className)}
      style={{ ...surface.style, borderRadius }}
    >
      {/* Top sheen */}
      <div
        className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-white/50 to-transparent rounded-full z-20"
        style={{ borderRadius }}
      />

      {/* Rim specular highlight overlay */}
      <div
        className="pointer-events-none absolute inset-0 z-0"
        style={{
          borderRadius,
          boxShadow:
            "inset 0 1px 0 rgba(255,255,255,0.25), inset 0 -1px 0 rgba(0,0,0,0.15)",
        }}
      />

      <div className="relative z-10 h-full">{children}</div>
    </motion.div>
  );
}

Open in interactive docs

LiquidGlassTabBar

Category: Navigation

Tab bar with default, pills, and underline variants.

Props

PropTypeRequiredDescription
tabs TabItem[] Yes -
activeIndex number Yes -
onChange (index: number) => void Yes Callback when the value changes.
className string No Additional Tailwind CSS classes.
variant "default" | "pills" | "underline" No Visual variant to use.

Usage

<LiquidGlassTabBar />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { useGlassSurface } from "./useGlassSurface";
import { GlassSheen } from "./GlassSheen";
import { useLiquidTransition } from "./useLiquidMotion";

interface TabItem {
  label: string;
  icon?: ReactNode;
  badge?: number;
}

interface LiquidGlassTabBarProps {
  tabs: TabItem[];
  activeIndex: number;
  onChange: (index: number) => void;
  className?: string;
  variant?: "default" | "pills" | "underline";
}

export function LiquidGlassTabBar({
  tabs,
  activeIndex,
  onChange,
  className,
  variant = "default",
}: LiquidGlassTabBarProps) {
  const transition = useLiquidTransition();
  const thumbSurface = useGlassSurface({ variant: "thumb" });
  if (variant === "pills") {
    return (
      <div
        className={cn(
          "relative inline-flex p-1 rounded-2xl",
          "glass-blur-sm glass-surface-dark glass-border",
          className
        )}
      >
        {tabs.map((tab, i) => (
          <motion.button
            key={i}
            onClick={() => onChange(i)}
            className={cn(
              "relative flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors z-10",
              activeIndex === i ? "text-[var(--lg-text)]" : "text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]"
            )}
          >
            {activeIndex === i && (
              <motion.div
                layoutId="tab-pill"
                className="absolute inset-0 rounded-xl glass-blur-lg pointer-events-none overflow-hidden"
                transition={transition}
                style={thumbSurface.style}
              >
                <div className="absolute inset-0 rounded-xl glass-reflection mix-blend-soft-light pointer-events-none" />
                <GlassSheen className="rounded-xl" opacity={0.18} />
              </motion.div>
            )}
            <span className="relative flex items-center gap-2">
              {tab.icon}
              {tab.label}
            </span>
          </motion.button>
        ))}
      </div>
    );
  }

  if (variant === "underline") {
    return (
      <div className={cn("relative flex border-b border-[var(--lg-border-subtle)]", className)}>
        {tabs.map((tab, i) => (
          <motion.button
            key={i}
            onClick={() => onChange(i)}
            className={cn(
              "relative flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors",
              activeIndex === i ? "text-[var(--lg-text)]" : "text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]"
            )}
          >
            {tab.icon}
            {tab.label}
            {activeIndex === i && (
              <motion.div
                layoutId="tab-underline"
                className="absolute bottom-0 left-2 right-2 h-1.5 rounded-full glass-blur-lg pointer-events-none overflow-hidden"
                transition={transition}
                style={thumbSurface.style}
              >
                <div className="absolute inset-0 rounded-full glass-reflection mix-blend-soft-light pointer-events-none" />
                <GlassSheen className="rounded-full" opacity={0.18} />
              </motion.div>
            )}
          </motion.button>
        ))}
      </div>
    );
  }

  return (
    <div
      className={cn(
        "relative flex",
        "glass-blur-sm glass-surface glass-border rounded-2xl overflow-hidden",
        className
      )}
    >
      {tabs.map((tab, i) => (
        <motion.button
          key={i}
          onClick={() => onChange(i)}
          className={cn(
            "relative flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors",
            activeIndex === i ? "text-[var(--lg-text)]" : "text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]"
          )}
        >
          {activeIndex === i && (
            <motion.div
              layoutId="tab-default"
              className="absolute inset-0.5 rounded-xl glass-blur-lg pointer-events-none overflow-hidden"
              transition={transition}
              style={thumbSurface.style}
            >
              <div className="absolute inset-0 rounded-xl glass-reflection mix-blend-soft-light pointer-events-none" />
              <GlassSheen className="rounded-xl" opacity={0.18} />
            </motion.div>
          )}
          <span className="relative flex items-center gap-2">
            {tab.icon}
            {tab.label}
          </span>
        </motion.button>
      ))}
    </div>
  );
}

Open in interactive docs

LiquidGlassTable

Category: Data Display

Sortable data table with glass styling.

Props

No props documented.

Usage

<LiquidGlassTable />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { useState } from "react";

interface TableColumn<T> {
  key: string;
  header: React.ReactNode;
  width?: string;
  sortable?: boolean;
  render?: (row: T) => React.ReactNode;
}

interface LiquidGlassTableProps<T> {
  columns: TableColumn<T>[];
  data: T[];
  className?: string;
  rowKey: (row: T) => string;
  onRowClick?: (row: T) => void;
  sortable?: boolean;
}

type SortState = { key: string; direction: "asc" | "desc" } | null;

export function LiquidGlassTable<T extends Record<string, unknown>>({
  columns,
  data,
  className,
  rowKey,
  onRowClick,
  sortable = true,
}: LiquidGlassTableProps<T>) {
  const [sort, setSort] = useState<SortState>(null);

  const sorted = sort
    ? [...data].sort((a, b) => {
        const aVal = String(a[sort.key] ?? "");
        const bVal = String(b[sort.key] ?? "");
        return sort.direction === "asc"
          ? aVal.localeCompare(bVal)
          : bVal.localeCompare(aVal);
      })
    : data;

  const toggleSort = (key: string) => {
    if (!sortable) return;
    setSort((prev) => {
      if (prev?.key === key) {
        return prev.direction === "asc" ? { key, direction: "desc" } : null;
      }
      return { key, direction: "asc" };
    });
  };

  const SortIcon = ({ colKey }: { colKey: string }) => {
    if (!sortable || sort?.key !== colKey) return <ArrowUpDown size={12} className="text-[var(--lg-text-muted)]" />;
    return sort.direction === "asc" ? (
      <ArrowUp size={12} className="text-liquid-blue" />
    ) : (
      <ArrowDown size={12} className="text-liquid-blue" />
    );
  };

  return (
    <div className={cn("overflow-hidden rounded-2xl glass-blur-sm glass-surface glass-border", className)}>
      <div className="overflow-x-auto">
        <table className="w-full">
          <thead>
            <tr className="border-b border-[var(--lg-border-subtle)]">
              {columns.map((col) => (
                <th
                  key={col.key}
                  style={{ width: col.width }}
                  onClick={() => col.sortable && toggleSort(col.key)}
                  className={cn(
                    "px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--lg-text-muted)]",
                    col.sortable && sortable && "cursor-pointer hover:text-[var(--lg-text-secondary)] select-none"
                  )}
                >
                  <div className="flex items-center gap-1.5">
                    {col.header}
                    {col.sortable && sortable && <SortIcon colKey={col.key} />}
                  </div>
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {sorted.map((row, i) => (
              <motion.tr
                key={rowKey(row)}
                initial={{ opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ delay: i * 0.03 }}
                onClick={() => onRowClick?.(row)}
                className={cn(
                  "border-b border-[var(--lg-border-subtle)] transition-colors",
                  onRowClick && "cursor-pointer hover:bg-[var(--lg-border-subtle)]",
                  i === sorted.length - 1 && "border-b-0"
                )}
              >
                {columns.map((col) => (
                  <td key={col.key} className="px-4 py-3 text-sm text-[var(--lg-text-secondary)]">
                    {col.render ? col.render(row) : String(row[col.key] ?? "-")}
                  </td>
                ))}
              </motion.tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

Open in interactive docs

LiquidGlassTimeline

Category: Data Display

Vertical timeline component.

Props

PropTypeRequiredDescription
items TimelineItem[] Yes -
className string No Additional Tailwind CSS classes.

Usage

<LiquidGlassTimeline />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { useGlassSurface } from "./useGlassSurface";

interface TimelineItem {
  id: string;
  title: string;
  description?: string;
  timestamp?: string;
  icon?: React.ReactNode;
  color?: "blue" | "purple" | "emerald" | "amber" | "rose" | "cyan";
}

interface LiquidGlassTimelineProps {
  items: TimelineItem[];
  className?: string;
}

const colorMap = {
  blue: "bg-liquid-blue",
  purple: "bg-liquid-purple",
  emerald: "bg-liquid-emerald",
  amber: "bg-liquid-amber",
  rose: "bg-liquid-rose",
  cyan: "bg-liquid-cyan",
};

export function LiquidGlassTimeline({
  items,
  className,
}: LiquidGlassTimelineProps) {
  const dotFill = useGlassSurface({ variant: "fill", opacity: 0.4 });
  return (
    <div className={cn("relative", className)}>
      {/* Vertical line */}
      <div className="absolute left-4 top-2 bottom-2 w-px bg-[var(--lg-border)]" />

      <div className="space-y-6">
        {items.map((item, i) => (
          <motion.div
            key={item.id}
            initial={{ opacity: 0, x: -10 }}
            animate={{ opacity: 1, x: 0 }}
            transition={{ delay: i * 0.1 }}
            className="relative flex items-start gap-4"
          >
            {/* Dot */}
            <div
              className={cn(
                "relative z-10 flex h-8 w-8 items-center justify-center rounded-full flex-shrink-0",
                "glass-blur-sm glass-border",
                item.color ? colorMap[item.color] + "/20" : "bg-[var(--lg-border-subtle)]"
              )}
            >
              <div
                className={cn(
                  "h-2.5 w-2.5 rounded-full",
                  item.color ? colorMap[item.color] : ""
                )}
                style={item.color ? undefined : { background: dotFill.style.background }}
              />
              {item.icon && (
                <span className="absolute text-[var(--lg-text-secondary)]">{item.icon}</span>
              )}
            </div>

            {/* Content */}
            <div className="flex-1 min-w-0 pt-1">
              <div className="flex items-center justify-between gap-2">
                <h4 className="text-sm font-medium text-[var(--lg-text-secondary)]">{item.title}</h4>
                {item.timestamp && (
                  <span className="text-xs text-[var(--lg-text-muted)] flex-shrink-0">{item.timestamp}</span>
                )}
              </div>
              {item.description && (
                <p className="text-xs text-[var(--lg-text-muted)] mt-1 leading-relaxed">{item.description}</p>
              )}
            </div>
          </motion.div>
        ))}
      </div>
    </div>
  );
}

Open in interactive docs

LiquidGlassToast

Category: Feedback & Status

Stacked toast notifications with progress bars.

Props

PropTypeRequiredDescription
toasts ToastItem[] Yes -
onRemove (id: string) => void Yes -
position "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center" No -

Usage

<LiquidGlassToast />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { X, CheckCircle, AlertTriangle, Info, AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useGlassSurface } from "./useGlassSurface";
import { GlassTopHighlight } from "./GlassTopHighlight";
import {
  useLiquidTapScale,
  useLiquidTransition,
  useGlassOverlayRootStyle,
} from "./useLiquidMotion";

export interface ToastItem {
  id: string;
  message: string;
  variant?: "info" | "success" | "warning" | "error";
  duration?: number;
}

interface LiquidGlassToastProps {
  toasts: ToastItem[];
  onRemove: (id: string) => void;
  position?: "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center";
}

const positionStyles = {
  "top-right": "top-4 right-4",
  "top-left": "top-4 left-4",
  "bottom-right": "bottom-4 right-4",
  "bottom-left": "bottom-4 left-4",
  "top-center": "top-4 left-1/2 -translate-x-1/2",
  "bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
};

const variantIcons = {
  info: <Info size={18} className="text-liquid-blue" />,
  success: <CheckCircle size={18} className="text-liquid-emerald" />,
  warning: <AlertTriangle size={18} className="text-liquid-amber" />,
  error: <AlertCircle size={18} className="text-liquid-rose" />,
};

const variantBorders = {
  info: "border-liquid-blue/20",
  success: "border-liquid-emerald/20",
  warning: "border-liquid-amber/20",
  error: "border-liquid-rose/20",
};

export function LiquidGlassToast({
  toasts,
  onRemove,
  position = "bottom-right",
}: LiquidGlassToastProps) {
  return (
    <div className={cn("fixed z-[100] flex flex-col gap-2", positionStyles[position])}>
      <AnimatePresence mode="popLayout">
        {toasts.map((toast) => (
          <ToastItemComponent
            key={toast.id}
            toast={toast}
            onRemove={onRemove}
          />
        ))}
      </AnimatePresence>
    </div>
  );
}

function ToastItemComponent({
  toast,
  onRemove,
}: {
  toast: ToastItem;
  onRemove: (id: string) => void;
}) {
  const tapScale = useLiquidTapScale();
  const transition = useLiquidTransition();
  const overlayRef = useGlassOverlayRootStyle(true);
  const progressFill = useGlassSurface({ variant: "fill", opacity: 0.2 });
  const [progress, setProgress] = useState(100);
  const duration = toast.duration || 4000;

  useEffect(() => {
    const start = Date.now();
    const interval = setInterval(() => {
      const elapsed = Date.now() - start;
      const remaining = Math.max(0, 100 - (elapsed / duration) * 100);
      setProgress(remaining);
      if (remaining <= 0) {
        clearInterval(interval);
        onRemove(toast.id);
      }
    }, 50);
    return () => clearInterval(interval);
  }, [duration, toast.id, onRemove]);

  return (
    <motion.div
      layout
      initial={{ opacity: 0.2, x: 50, scale: 0.9 }}
      animate={{ opacity: 1, x: 0, scale: 1 }}
      exit={{ opacity: 0, x: 50, scale: 0.9 }}
      transition={transition}
      ref={overlayRef}
      className={cn(
        "relative flex items-center gap-3 min-w-[280px] max-w-[400px] px-4 py-3 rounded-2xl",
        "glass-blur-xl glass-surface border",
        variantBorders[toast.variant || "info"],
        "glass-highlight"
      )}
    >
      {/* Top highlight */}
      <GlassTopHighlight className="inset-x-0 top-0" opacity={0.2} />
      
      <div className="flex-shrink-0">{variantIcons[toast.variant || "info"]}</div>
      <p className="flex-1 text-sm text-[var(--lg-text-secondary)] leading-relaxed">{toast.message}</p>
      <motion.button
        whileHover={{ scale: 1.1 }}
        whileTap={{ scale: tapScale }}
        onClick={() => onRemove(toast.id)}
        className="flex-shrink-0 text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)] transition-colors"
      >
        <X size={14} />
      </motion.button>
      
      {/* Progress bar */}
      <div className="absolute bottom-0 left-0 right-0 h-0.5 rounded-b-2xl overflow-hidden">
        <motion.div
          className="h-full"
          style={{ width: `${progress}%`, background: progressFill.style.background }}
        />
      </div>
    </motion.div>
  );
}

Open in interactive docs

LiquidGlassToggle

Category: Inputs, Toggles & Pickers

Fluid or iOS 26 style toggle switch.

Props

PropTypeRequiredDescription
activeTint string No Tint color for the active track. iOS 26 uses system green by default, but can be overridden per context.

Usage

<LiquidGlassToggle />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useCallback, useMemo, type MouseEvent } from "react";
import { useGlassSurface } from "./useGlassSurface";
import { useGlassFluidity } from "./useGlassFluidity";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { GlassSheen } from "./GlassSheen";

interface LiquidGlassToggleProps {
  checked?: boolean;
  onChange?: (checked: boolean) => void;
  className?: string;
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
  label?: string;
  description?: string;
  variant?: "ios26" | "fluid";
  /**
   * Tint color for the active track. iOS 26 uses system green by default,
   * but can be overridden per context.
   */
  activeTint?: string;
}

const sizeStyles = {
  // Wide recessed track with a lozenge thumb that bulges out when pressed.
  sm: { track: "w-12 h-6", thumb: 18, widthRatio: 1.4, padding: 3.5 },
  md: { track: "w-[4rem] h-7", thumb: 22, widthRatio: 1.4, padding: 3.5 },
  lg: { track: "w-[5rem] h-9", thumb: 28, widthRatio: 1.4, padding: 3.5 },
};

export function LiquidGlassToggle({
  checked = false,
  onChange,
  className,
  size = "md",
  disabled,
  label,
  description,
  variant = "fluid",
  activeTint,
}: LiquidGlassToggleProps) {
  const tint = activeTint ?? (variant === "ios26" ? "#34c759" : "#3b82f6");
  const s = sizeStyles[size];
  const idleWidth = s.thumb * s.widthRatio;
  const [ripples, setRipples] = useState<{ id: number; x: number; y: number }[]>([]);
  const [pressPoint, setPressPoint] = useState({ x: 50, y: 50 });
  const [isPressed, setIsPressed] = useState(false);
  const [isMoving, setIsMoving] = useState(false);
  const fluidity = useGlassFluidity();

  const thumbSurface = useGlassSurface({ variant: "thumb" });
  const trackSurface = useGlassSurface({ variant: "track" });
  const trackActiveSurface = useGlassSurface({ variant: "track-active", tint, activeTint: tint });
  const trackLensingChecked = useGlassSurface({ variant: "fill", opacity: 0.24 });
  const trackLensingUnchecked = useGlassSurface({ variant: "fill", opacity: 0.14 });

  // Snappy spring so the shape snaps back immediately when the state change finishes.
  const spring = useMemo(() => {
    const stiffness = 720 - fluidity * 3.6;
    const damping = 46 - fluidity * 0.2;
    return { stiffness, damping };
  }, [fluidity]);

  const createRipple = useCallback(
    (e: MouseEvent<HTMLButtonElement>) => {
      if (disabled) return;
      const rect = e.currentTarget.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      setPressPoint({ x: (x / rect.width) * 100, y: (y / rect.height) * 100 });
      const id = Date.now();
      setRipples((prev) => [...prev, { id, x, y }]);
      setTimeout(() => setRipples((prev) => prev.filter((r) => r.id !== id)), 700);
    },
    [disabled]
  );

  // Compute travel from the idle width so the lozenge stays within the track at rest.
  const trackWidth = useMemo(() => {
    // Parse Tailwind class widths into pixels (rem = 16px).
    if (s.track.includes("w-[")) {
      const match = s.track.match(/w-\[(.+?)\]/);
      const val = match?.[1] ?? "3rem";
      if (val.endsWith("rem")) return parseFloat(val) * 16;
      return parseFloat(val);
    }
    // Tailwind w-12 = 48.
    if (s.track.includes("w-12")) return 48;
    return 48;
  }, [s.track]);

  const travel = trackWidth - idleWidth - s.padding * 2;

  // Width-based stretch keeps semicircular ends (stadium/lozenge) instead of an ellipse.
  const thumbWidth = isPressed
    ? idleWidth * 1.65
    : isMoving
      ? idleWidth * 1.45
      : idleWidth;

  // Slight vertical expansion so the thumb bulges out of the track when active.
  const scaleY = isPressed
    ? 1.18
    : isMoving
      ? 1.12
      : 1;

  return (
    <div className={cn("inline-flex items-center gap-4", className)}>
      <motion.button
        disabled={disabled}
        onPointerDown={() => setIsPressed(true)}
        onPointerUp={() => setIsPressed(false)}
        onPointerLeave={() => setIsPressed(false)}
        onClick={(e) => {
          createRipple(e);
          if (!disabled) {
            setIsMoving(true);
            onChange?.(!checked);
          }
        }}
        className={cn(
          "relative inline-flex items-center rounded-full isolate",
          "overflow-visible",
          s.track,
          disabled && "opacity-40 cursor-not-allowed"
        )}
      >
        {/* Track — recessed glass pill underneath the thumb */}
        <div
          className={cn(
            "absolute inset-0 rounded-full -z-10",
            "glass-blur-xl",
            "border border-[var(--lg-border)]"
          )}
          style={{
            background: checked ? trackActiveSurface.style.background : trackSurface.style.background,
            boxShadow: trackSurface.style.boxShadow,
          }}
        >
          {/* Lensing backdrop layer */}
          <div
            className="absolute inset-[-1px] rounded-full opacity-70 pointer-events-none"
            style={{
              background: checked
                ? `${trackLensingChecked.style.background}, radial-gradient(circle at 75% 85%, ${tint}18 0%, transparent 45%)`
                : trackLensingUnchecked.style.background,
            }}
          />

          {/* Top highlight line */}
          <GlassTopHighlight className="inset-x-3 top-[1px]" opacity={0.35} />
        </div>

        {/* Thumb — lozenge at rest; expands into a wider, taller stadium shape when active. */}
        <motion.div
          initial={{ width: idleWidth, marginLeft: -idleWidth / 2 }}
          animate={{
            x: checked ? travel : 0,
            width: thumbWidth,
            marginLeft: -thumbWidth / 2,
            scaleY,
          }}
          transition={{
            x: { type: "spring", stiffness: spring.stiffness, damping: spring.damping, mass: 0.45 },
            width: { type: "spring", stiffness: spring.stiffness, damping: spring.damping, mass: 0.45 },
            marginLeft: { type: "spring", stiffness: spring.stiffness, damping: spring.damping, mass: 0.45 },
            scaleY: { type: "spring", stiffness: spring.stiffness, damping: spring.damping, mass: 0.45 },
          }}
          onAnimationComplete={() => {
            // Return to idle exactly when the motion finishes.
            if (!isPressed) setIsMoving(false);
          }}
          className={cn(
            "absolute top-1/2 -translate-y-1/2 rounded-full z-10 pointer-events-none",
            thumbSurface.className
          )}
          style={{
            left: s.padding + idleWidth / 2,
            height: s.thumb,
            ...thumbSurface.style,
          }}
        >
          {/* Reflection response */}
          <div className="absolute inset-0 rounded-full glass-reflection mix-blend-soft-light pointer-events-none" />

          {/* Subtle specular sheen */}
          <GlassSheen className="rounded-full" opacity={0.18} />

          {/* Touch-point shimmer */}
          <AnimatePresence>
            {isPressed && (
              <motion.div
                initial={{ opacity: 0, scale: 0.7 }}
                animate={{ opacity: 0.55, scale: 1 }}
                exit={{ opacity: 0, scale: 1.15 }}
                transition={{ duration: 0.2 }}
                className="absolute inset-[-2px] rounded-full"
                style={{
                  background: `radial-gradient(circle at ${pressPoint.x}% ${pressPoint.y}%, rgba(255,255,255,0.45) 0%, transparent 55%)`,
                }}
              />
            )}
          </AnimatePresence>
        </motion.div>

        {/* Ripple */}
        <AnimatePresence>
          {ripples.map((ripple) => (
            <motion.span
              key={ripple.id}
              initial={{ scale: 0, opacity: 0.5, borderRadius: "45% 55% 50% 50% / 55% 45% 50% 50%" }}
              animate={{ scale: 2.8, opacity: 0, borderRadius: "50%" }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.7, ease: "easeOut" }}
              className="absolute pointer-events-none z-20"
              style={{
                left: ripple.x,
                top: ripple.y,
                width: 40,
                height: 40,
                marginLeft: -20,
                marginTop: -20,
                background: "radial-gradient(circle, rgba(255,255,255,0.25) 0%, transparent 70%)",
              }}
            />
          ))}
        </AnimatePresence>
      </motion.button>

      {(label || description) && (
        <div className="flex flex-col">
          {label && (
            <span className={cn("text-sm font-medium", disabled ? "text-[var(--lg-text-muted)]" : "text-[var(--lg-text)]")}>
              {label}
            </span>
          )}
          {description && <span className="text-xs text-[var(--lg-text-muted)]">{description}</span>}
        </div>
      )}
    </div>
  );
}

Open in interactive docs

LiquidGlassTooltip

Category: Overlays, Menus & Tooltips

Hover tooltip with directional positioning.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
content ReactNode Yes -
className string No Additional Tailwind CSS classes.
position "top" | "bottom" | "left" | "right" No -
delay number No -

Usage

<LiquidGlassTooltip />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, type ReactNode } from "react";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { useGlassOverlayRootStyle } from "./useLiquidMotion";

interface LiquidGlassTooltipProps {
  children: ReactNode;
  content: ReactNode;
  className?: string;
  position?: "top" | "bottom" | "left" | "right";
  delay?: number;
}

const positionStyles = {
  top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
  bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
  left: "right-full top-1/2 -translate-y-1/2 mr-2",
  right: "left-full top-1/2 -translate-y-1/2 ml-2",
};

const arrowStyles = {
  top: "top-full left-1/2 -translate-x-1/2 -mt-px border-l-transparent border-r-transparent border-b-transparent",
  bottom: "bottom-full left-1/2 -translate-x-1/2 -mb-px border-l-transparent border-r-transparent border-t-transparent",
  left: "left-full top-1/2 -translate-y-1/2 -ml-px border-t-transparent border-b-transparent border-r-transparent",
  right: "right-full top-1/2 -translate-y-1/2 -mr-px border-t-transparent border-b-transparent border-l-transparent",
};

export function LiquidGlassTooltip({
  children,
  content,
  className,
  position = "top",
  delay = 0.3,
}: LiquidGlassTooltipProps) {
  const [isVisible, setIsVisible] = useState(false);
  const overlayRef = useGlassOverlayRootStyle(isVisible);
  let timeoutId: ReturnType<typeof setTimeout>;

  const show = () => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => setIsVisible(true), delay * 1000);
  };

  const hide = () => {
    clearTimeout(timeoutId);
    setIsVisible(false);
  };

  return (
    <div
      className={cn("relative inline-flex", className)}
      onMouseEnter={show}
      onMouseLeave={hide}
      onFocus={show}
      onBlur={hide}
    >
      {children}
      <AnimatePresence>
        {isVisible && (
          <motion.div
            initial={{ opacity: 0.2, scale: 0.9 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.9 }}
            transition={{ duration: 0.15 }}
            ref={overlayRef}
            className={cn(
              "absolute z-50 px-3 py-2 rounded-xl whitespace-nowrap",
              "glass-blur-lg glass-surface glass-border glass-highlight",
              "text-xs font-medium text-[var(--lg-text)]",
              positionStyles[position]
            )}
          >
            {/* Arrow */}
            <div
              className={cn(
                "absolute w-2 h-2 rotate-45",
                "bg-[var(--lg-border)] border border-[var(--lg-border-subtle)]",
                arrowStyles[position]
              )}
            />
            {content}
            {/* Top highlight */}
            <GlassTopHighlight className="inset-x-2 top-0" opacity={0.2} />
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Open in interactive docs

LiquidGlassWeatherWidget

Category: Media & Content

Weather widget with hourly forecast.

Props

PropTypeRequiredDescription
data WeatherData No -
className string No Additional Tailwind CSS classes.

Usage

<LiquidGlassWeatherWidget />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { Cloud, Sun, CloudRain, CloudSnow, Wind, Droplets, Eye, Gauge } from "lucide-react";
import { GlassTopHighlight } from "./GlassTopHighlight";

interface WeatherData {
  temp: number;
  condition: "sunny" | "cloudy" | "rainy" | "snowy";
  location: string;
  high: number;
  low: number;
  humidity: number;
  wind: number;
  visibility: number;
  pressure: number;
  hourly: { time: string; temp: number; icon: "sun" | "cloud" | "rain" }[];
}

interface LiquidGlassWeatherWidgetProps {
  data?: WeatherData;
  className?: string;
}

const conditionConfig = {
  sunny: { icon: Sun, color: "text-liquid-amber", bg: "from-liquid-amber/10 to-transparent" },
  cloudy: { icon: Cloud, color: "text-[var(--lg-text-secondary)]", bg: "from-white/5 to-transparent" },
  rainy: { icon: CloudRain, color: "text-liquid-blue", bg: "from-liquid-blue/10 to-transparent" },
  snowy: { icon: CloudSnow, color: "text-[var(--lg-text-secondary)]", bg: "from-white/10 to-transparent" },
};

const hourlyIconMap = {
  sun: Sun,
  cloud: Cloud,
  rain: CloudRain,
};

const defaultData: WeatherData = {
  temp: 72,
  condition: "sunny",
  location: "San Francisco, CA",
  high: 78,
  low: 62,
  humidity: 45,
  wind: 8,
  visibility: 10,
  pressure: 30.12,
  hourly: [
    { time: "Now", temp: 72, icon: "sun" },
    { time: "1PM", temp: 74, icon: "sun" },
    { time: "2PM", temp: 76, icon: "cloud" },
    { time: "3PM", temp: 75, icon: "cloud" },
    { time: "4PM", temp: 73, icon: "rain" },
    { time: "5PM", temp: 70, icon: "rain" },
  ],
};

export function LiquidGlassWeatherWidget({
  data = defaultData,
  className,
}: LiquidGlassWeatherWidgetProps) {
  const config = conditionConfig[data.condition];
  const ConditionIcon = config.icon;

  return (
    <div
      className={cn(
        "w-full max-w-sm p-5 rounded-3xl overflow-hidden relative",
        "glass-blur-lg glass-surface glass-border glass-highlight-strong",
        className
      )}
    >
      {/* Top highlight */}
      <GlassTopHighlight className="inset-x-0 top-0" opacity={0.25} />
      {/* Background glow */}
      <div className={cn("absolute -top-20 -right-20 h-40 w-40 rounded-full blur-3xl bg-gradient-to-br", config.bg)} />

      {/* Main weather */}
      <div className="relative flex items-start justify-between mb-5">
        <div>
          <p className="text-xs text-[var(--lg-text-muted)] mb-1">{data.location}</p>
          <div className="flex items-baseline gap-1">
            <span className="text-5xl font-light text-[var(--lg-text)]">{data.temp}°</span>
          </div>
          <p className={cn("text-sm font-medium mt-1", config.color)}>
            {data.condition.charAt(0).toUpperCase() + data.condition.slice(1)}
          </p>
          <p className="text-xs text-[var(--lg-text-muted)] mt-0.5">
            H:{data.high}°  L:{data.low}°
          </p>
        </div>
        <motion.div
          animate={{ rotate: [0, 5, -5, 0] }}
          transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }}
        >
          <ConditionIcon size={56} className={config.color} strokeWidth={1.2} />
        </motion.div>
      </div>

      {/* Hourly forecast */}
      <div className="flex justify-between mb-5 px-1">
        {data.hourly.map((h, i) => {
          const Icon = hourlyIconMap[h.icon];
          return (
            <div key={i} className="flex flex-col items-center gap-1.5">
              <span className="text-[10px] text-[var(--lg-text-muted)]">{h.time}</span>
              <Icon size={14} className="text-[var(--lg-text-muted)]" />
              <span className="text-xs font-medium text-[var(--lg-text-secondary)]">{h.temp}°</span>
            </div>
          );
        })}
      </div>

      {/* Details grid */}
      <div className="grid grid-cols-2 gap-2">
        <DetailItem icon={<Droplets size={14} />} label="Humidity" value={`${data.humidity}%`} />
        <DetailItem icon={<Wind size={14} />} label="Wind" value={`${data.wind} mph`} />
        <DetailItem icon={<Eye size={14} />} label="Visibility" value={`${data.visibility} mi`} />
        <DetailItem icon={<Gauge size={14} />} label="Pressure" value={`${data.pressure}"`} />
      </div>
    </div>
  );
}

function DetailItem({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
  return (
    <div className="flex items-center gap-2.5 p-2.5 rounded-xl bg-[var(--lg-border-subtle)]">
      <span className="text-[var(--lg-text-muted)]">{icon}</span>
      <div>
        <p className="text-[10px] text-[var(--lg-text-muted)]">{label}</p>
        <p className="text-xs font-medium text-[var(--lg-text-secondary)]">{value}</p>
      </div>
    </div>
  );
}

Open in interactive docs

MobileActionSheet

Category: Modals, Drawers & Sheets

Bottom action sheet with default, grouped, minimal, and grid variants.

Props

PropTypeRequiredDescription
isOpen boolean Yes Controlled open state.
onClose () => void Yes Callback when the component requests to close.
title string No Title text.
subtitle string No -
items ActionSheetItem[] Yes -
cancelText string No -
className string No Additional Tailwind CSS classes.
variant "default" | "grouped" | "minimal" | "grid" No Visual variant to use.

Usage

<MobileActionSheet />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import type { ReactNode } from "react";
import {
  useLiquidSlideVariants,
  useLiquidTransition,
  useLiquidTapScale,
  useGlassOverlayRootStyle,
} from "./useLiquidMotion";

interface ActionSheetItem {
  id: string;
  title: string;
  subtitle?: string;
  icon?: ReactNode;
  destructive?: boolean;
  onClick?: () => void;
}

interface MobileActionSheetProps {
  isOpen: boolean;
  onClose: () => void;
  title?: string;
  subtitle?: string;
  items: ActionSheetItem[];
  cancelText?: string;
  className?: string;
  variant?: "default" | "grouped" | "minimal" | "grid";
}

export function MobileActionSheet({
  isOpen,
  onClose,
  title,
  subtitle,
  items,
  cancelText = "Cancel",
  className,
  variant = "default",
}: MobileActionSheetProps) {
  const slideVariants = useLiquidSlideVariants("bottom", { stiff: true });
  const transition = useLiquidTransition({ stiff: true });
  const tapScale = useLiquidTapScale();
  const overlayRef = useGlassOverlayRootStyle(isOpen);
  return (
    <AnimatePresence>
      {isOpen && (
        <div className="fixed inset-0 z-[60] flex items-end justify-center" ref={overlayRef}>
          <motion.div initial={{ opacity: 0.2 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }} onClick={onClose}
            className="glass-backdrop-overlay" />

          <motion.div
            {...slideVariants}
            transition={transition}
            className={cn(
              "relative w-full max-w-lg mx-auto",
              variant === "grouped" ? "px-3 pb-3" : "pb-2",
              className
            )}
          >
            {/* ─── GROUPED ─── */}
            {variant === "grouped" && (
              <>
                <div className="glass-blur-xl glass-surface-strong glass-border glass-highlight-strong rounded-2xl overflow-hidden mb-2">
                  <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
                  {(title || subtitle) && (
                    <div className="px-5 pt-4 pb-3 text-center border-b border-[var(--lg-border)]">
                      {title && <h3 className="text-sm font-semibold text-[var(--lg-text)]">{title}</h3>}
                      {subtitle && <p className="text-xs text-[var(--lg-text-muted)] mt-1">{subtitle}</p>}
                    </div>
                  )}
                  {items.map((item, i) => (
                    <ActionButton key={item.id} item={item} onClose={onClose} isLast={i === items.length - 1} />
                  ))}
                </div>
                <motion.button whileTap={{ scale: tapScale }} onClick={onClose}
                  className="w-full py-3.5 rounded-2xl text-sm font-semibold text-liquid-blue glass-blur-xl glass-surface-strong glass-border glass-highlight-strong">
                  {cancelText}
                </motion.button>
              </>
            )}

            {/* ─── GRID ─── */}
            {variant === "grid" && (
              <div className="glass-blur-xl glass-surface-strong glass-border glass-highlight-strong rounded-t-3xl overflow-hidden">
                <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
                <div className="flex justify-center pt-3 pb-1"><div className="w-10 h-1 rounded-full bg-[var(--lg-border)]" /></div>
                {(title || subtitle) && (
                  <div className="px-5 pt-1 pb-3 text-center">
                    {title && <h3 className="text-sm font-semibold text-[var(--lg-text)]">{title}</h3>}
                    {subtitle && <p className="text-xs text-[var(--lg-text-muted)] mt-1">{subtitle}</p>}
                  </div>
                )}
                <div className="grid grid-cols-4 gap-2 px-4 pb-6">
                  {items.map((item) => (
                    <motion.button key={item.id} whileTap={{ scale: tapScale }} onClick={() => { item.onClick?.(); onClose(); }}
                      className="flex flex-col items-center gap-1.5 py-3 rounded-xl hover:bg-[var(--lg-border)] transition-colors">
                      <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--lg-border)]">
                        <span className={item.destructive ? "text-liquid-rose" : "text-[var(--lg-text-secondary)]"}>{item.icon}</span>
                      </div>
                      <span className={cn("text-[10px] font-medium", item.destructive ? "text-liquid-rose" : "text-[var(--lg-text-muted)]")}>
                        {item.title}
                      </span>
                    </motion.button>
                  ))}
                </div>
              </div>
            )}

            {/* ─── MINIMAL ─── */}
            {variant === "minimal" && (
              <div className="glass-blur-xl glass-surface-strong glass-border glass-highlight-strong rounded-t-3xl overflow-hidden">
                <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
                <div className="flex justify-center pt-3 pb-1"><div className="w-10 h-1 rounded-full bg-[var(--lg-border)]" /></div>
                <div className="px-4 pb-6 pt-2 space-y-0.5">
                  {items.map((item, i) => (
                    <ActionButton key={item.id} item={item} onClose={onClose} isLast={i === items.length - 1} />
                  ))}
                </div>
              </div>
            )}

            {/* ─── DEFAULT ─── */}
            {variant === "default" && (
              <div className="glass-blur-xl glass-surface-strong glass-border glass-highlight-strong rounded-t-3xl overflow-hidden">
                <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
                <div className="pointer-events-none absolute -top-10 -right-10 h-24 w-24 rounded-full bg-[var(--lg-reflection)] blur-2xl" />
                <div className="flex justify-center pt-3 pb-1"><div className="w-10 h-1 rounded-full bg-[var(--lg-border)]" /></div>
                {(title || subtitle) && (
                  <div className="px-6 pt-2 pb-4 text-center">
                    {title && <h3 className="text-base font-semibold text-[var(--lg-text)]">{title}</h3>}
                    {subtitle && <p className="text-xs text-[var(--lg-text-muted)] mt-1">{subtitle}</p>}
                  </div>
                )}
                <div className="px-2 pb-6 space-y-0.5">
                  {items.map((item, i) => (
                    <ActionButton key={item.id} item={item} onClose={onClose} isLast={i === items.length - 1} />
                  ))}
                  <motion.button whileTap={{ scale: tapScale }} onClick={onClose}
                    className="flex w-full items-center justify-center py-3.5 rounded-xl text-sm font-semibold text-[var(--lg-text-muted)] hover:bg-[var(--lg-border)] transition-colors mt-2">
                    {cancelText}
                  </motion.button>
                </div>
              </div>
            )}
          </motion.div>
        </div>
      )}
    </AnimatePresence>
  );
}

function ActionButton({ item, onClose, isLast }: { item: ActionSheetItem; onClose: () => void; isLast: boolean }) {
  const actionTapScale = useLiquidTapScale();
  return (
    <motion.button
      whileTap={{ scale: actionTapScale }}
      onClick={() => { item.onClick?.(); onClose(); }}
      className={cn(
        "flex w-full items-center gap-3 px-4 py-3.5 rounded-xl text-left transition-colors hover:bg-[var(--lg-border)]",
        !isLast && "border-b border-[var(--lg-border-subtle)]",
        item.destructive ? "text-liquid-rose" : "text-[var(--lg-text)]"
      )}
    >
      {item.icon && <span className={item.destructive ? "text-liquid-rose" : "text-[var(--lg-text-muted)]"}>{item.icon}</span>}
      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium">{item.title}</p>
        {item.subtitle && <p className="text-xs text-[var(--lg-text-muted)] mt-0.5">{item.subtitle}</p>}
      </div>
    </motion.button>
  );
}

Open in interactive docs

MobileAlertDialog

Category: Modals, Drawers & Sheets

Mobile alert dialog with options.

Props

PropTypeRequiredDescription
isOpen boolean Yes Controlled open state.
onClose () => void Yes Callback when the component requests to close.
title string No Title text.
message string No Message content.
options AlertOption[] No -
icon "info" | "warning" | "success" | "error" | ReactNode No Icon element to display.
className string No Additional Tailwind CSS classes.

Usage

<MobileAlertDialog />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { X, Check, AlertTriangle } from "lucide-react";
import type { ReactNode } from "react";
import { GlassTopHighlight } from "./GlassTopHighlight";
import {
  useLiquidOverlayVariants,
  useLiquidTransition,
  useLiquidTapScale,
  useGlassOverlayRootStyle,
} from "./useLiquidMotion";

interface AlertOption {
  text: string;
  style?: "default" | "destructive" | "cancel";
  onClick?: () => void;
}

interface MobileAlertDialogProps {
  isOpen: boolean;
  onClose: () => void;
  title?: string;
  message?: string;
  options?: AlertOption[];
  icon?: "info" | "warning" | "success" | "error" | ReactNode;
  className?: string;
}

export function MobileAlertDialog({
  isOpen,
  onClose,
  title = "Alert",
  message,
  options,
  icon = "info",
  className,
}: MobileAlertDialogProps) {
  const overlayVariants = useLiquidOverlayVariants();
  const transition = useLiquidTransition();
  const tapScale = useLiquidTapScale();
  const overlayRef = useGlassOverlayRootStyle(isOpen);
  const defaultOptions: AlertOption[] = options || [
    { text: "OK", style: "cancel", onClick: onClose },
  ];

  const iconMap = {
    info: <InfoIcon />,
    warning: <AlertTriangle size={22} className="text-liquid-amber" />,
    success: <Check size={22} className="text-liquid-emerald" />,
    error: <X size={22} className="text-liquid-rose" />,
  };

  const alertIcon = typeof icon === "string" ? iconMap[icon as keyof typeof iconMap] : icon;

  return (
    <AnimatePresence>
      {isOpen && (
        <div className="fixed inset-0 z-[60] flex items-center justify-center p-6" ref={overlayRef}>
          {/* Backdrop */}
          <motion.div
            initial={{ opacity: 0.2 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
            className="glass-backdrop"
          />

          {/* Dialog */}
          <motion.div
            {...overlayVariants}
            transition={transition}
            className={cn(
              "relative w-full max-w-sm",
              "glass-blur-xl glass-surface glass-border glass-highlight-strong",
              "rounded-2xl overflow-hidden",
              className
            )}
          >
            {/* Top highlight */}
            <GlassTopHighlight className="inset-x-0 top-0" opacity={0.25} />

            {/* Content */}
            <div className="px-6 py-5 text-center">
              <div className="flex justify-center mb-3">{alertIcon}</div>
              <h3 className="text-base font-semibold text-[var(--lg-text)] mb-1.5">
                {title}
              </h3>
              {message && (
                <p className="text-sm text-[var(--lg-text-muted)] leading-relaxed">{message}</p>
              )}
            </div>

            {/* Options */}
            <div className="px-3 pb-3 space-y-1">
              {defaultOptions.map((option, i) => (
                <motion.button
                  key={i}
                  initial={{ opacity: 0, y: 10 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ ...transition, delay: i * 0.08 }}
                  whileTap={{ scale: tapScale }}
                  onClick={() => {
                    option.onClick?.();
                    onClose();
                  }}
                  className={cn(
                    "w-full py-3 rounded-xl text-sm font-semibold transition-colors",
                    i < defaultOptions.length - 1 && "border-b border-[var(--lg-border-subtle)]",
                    option.style === "destructive"
                      ? "text-liquid-rose hover:bg-liquid-rose/5"
                      : option.style === "cancel"
                      ? "text-[var(--lg-text-muted)] hover:bg-[var(--lg-border-subtle)]"
                      : "text-liquid-blue hover:bg-[var(--lg-border-subtle)]"
                  )}
                >
                  {option.text}
                </motion.button>
              ))}
            </div>
          </motion.div>
        </div>
      )}
    </AnimatePresence>
  );
}

function InfoIcon() {
  return (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-liquid-blue">
      <circle cx="12" cy="12" r="10" />
      <path d="M12 16v-4" />
      <path d="M12 8h.01" />
    </svg>
  );
}

Open in interactive docs

MobileAppRating

Category: Modals, Drawers & Sheets

App rating modal with star selection.

Props

PropTypeRequiredDescription
isOpen boolean Yes Controlled open state.
onClose () => void Yes Callback when the component requests to close.
onRate (rating: number) => void Yes -
title string No Title text.
message string No Message content.
appName string No -
className string No Additional Tailwind CSS classes.

Usage

<MobileAppRating />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { Star } from "lucide-react";
import { useState } from "react";
import { useGlassSurface } from "./useGlassSurface";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { useGlassOverlayRootStyle } from "./useLiquidMotion";

interface MobileAppRatingProps {
  isOpen: boolean;
  onClose: () => void;
  onRate: (rating: number) => void;
  title?: string;
  message?: string;
  appName?: string;
  className?: string;
}

export function MobileAppRating({
  isOpen,
  onClose,
  onRate,
  title = "Rate this app",
  message = "If you enjoy using this app, please take a moment to rate it. Your feedback helps us improve!",
  appName = "Liquid Glass",
  className,
}: MobileAppRatingProps) {
  const disabledFill = useGlassSurface({ variant: "fill", opacity: 0.05 });
  const [rating, setRating] = useState(0);
  const [hoverRating, setHoverRating] = useState(0);
  const displayRating = hoverRating || rating;
  const overlayRef = useGlassOverlayRootStyle(isOpen);

  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          initial={{ opacity: 0.2 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.2 }}
          ref={overlayRef}
          className="fixed inset-0 z-[60] flex items-center justify-center p-6"
        >
          <motion.div
            initial={{ opacity: 0.2 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
            className="glass-backdrop"
          />

          <motion.div
            initial={{ opacity: 0.2, scale: 0.9, y: 20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.9, y: 20 }}
            className={cn(
              "relative w-full max-w-sm",
              "glass-blur-xl glass-surface glass-border glass-highlight-strong",
              "rounded-2xl overflow-hidden",
              className
            )}
          >
            <GlassTopHighlight className="inset-x-0 top-0" opacity={0.25} />

            <div className="px-6 py-8 text-center">
              {/* App icon */}
              <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-liquid-blue to-liquid-purple">
                <Star size={28} className="text-white" />
              </div>

              <h3 className="text-lg font-semibold text-[var(--lg-text)] mb-2">{title} — {appName}</h3>
              <p className="text-sm text-[var(--lg-text-muted)] leading-relaxed mb-6">{message}</p>

              {/* Stars */}
              <div className="flex items-center justify-center gap-1 mb-6">
                {[1, 2, 3, 4, 5].map((star) => (
                  <motion.button
                    key={star}
                    whileTap={{ scale: 0.85 }}
                    onMouseEnter={() => setHoverRating(star)}
                    onMouseLeave={() => setHoverRating(0)}
                    onClick={() => setRating(star)}
                    className="flex items-center justify-center"
                  >
                    <Star
                      size={32}
                      className={cn(
                        "transition-colors",
                        star <= displayRating
                          ? "text-liquid-amber fill-liquid-amber"
                          : "text-[var(--lg-text-muted)]"
                      )}
                    />
                  </motion.button>
                ))}
              </div>

              {/* Buttons */}
              <div className="flex gap-3">
                <motion.button
                  whileTap={{ scale: 0.98 }}
                  onClick={onClose}
                  className="flex-1 py-3 rounded-xl text-sm font-semibold text-[var(--lg-text-muted)] hover:bg-[var(--lg-border-subtle)] transition-colors"
                >
                  Remind Later
                </motion.button>
                <motion.button
                  whileTap={{ scale: 0.98 }}
                  onClick={() => {
                    if (rating > 0) onRate(rating);
                    onClose();
                  }}
                  disabled={rating === 0}
                  className={cn(
                    "flex-1 py-3 rounded-xl text-sm font-semibold transition-all",
                    rating > 0
                      ? "text-white glass-blur-sm bg-liquid-blue/20 border border-liquid-blue/30"
                      : "text-white/20"
                  )}
                  style={rating > 0 ? undefined : { background: disabledFill.style.background }}
                >
                  Submit Rating
                </motion.button>
              </div>
            </div>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

Open in interactive docs

MobileBottomTabBar

Category: Navigation

iOS/Android bottom tab bar with 8 visual variants.

Props

PropTypeRequiredDescription
tabs TabItem[] Yes -
activeTab string Yes -
onChange (id: string) => void No Callback when the value changes.
className string No Additional Tailwind CSS classes.
showLabels boolean No -
hideActiveLabel boolean No -
variant MobileBottomTabVariant No Visual variant to use.
centerTabButton { No -
icon ReactNode Yes Icon element to display.
label string Yes Label text.
onClick () => void Yes Callback when the element is clicked.
trailingButton { No -
icon ReactNode Yes Icon element to display.
label string No Label text.
onClick () => void Yes Callback when the element is clicked.

Usage

<MobileBottomTabBar />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import {
  useState,
  useCallback,
  useRef,
  useLayoutEffect,
  useEffect,
  type ReactNode,
  type MouseEvent,
} from "react";
import { useTheme } from "./ThemeProvider";
import { useGlassSurface } from "./useGlassSurface";
import { useGlassFluidity } from "./useGlassFluidity";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { GlassSheen } from "./GlassSheen";
import { useLiquidTapScale } from "./useLiquidMotion";

interface TabItem {
  id: string;
  icon: ReactNode;
  activeIcon?: ReactNode;
  label: string;
  badge?: number;
  onClick?: () => void;
}

export type MobileBottomTabVariant =
  | "default"
  | "pill"
  | "floating"
  | "ios26-fluid"
  | "ios26-chrome"
  | "ios26-glow"
  | "ios26-dock"
  | "ios26-super-pill";

interface MobileBottomTabBarProps {
  tabs: TabItem[];
  activeTab: string;
  onChange?: (id: string) => void;
  className?: string;
  showLabels?: boolean;
  hideActiveLabel?: boolean;
  variant?: MobileBottomTabVariant;
  centerTabButton?: {
    icon: ReactNode;
    label: string;
    onClick: () => void;
  };
  trailingButton?: {
    icon: ReactNode;
    label?: string;
    onClick: () => void;
  };
}

export function MobileBottomTabBar({
  tabs,
  activeTab,
  onChange,
  className,
  showLabels = true,
  hideActiveLabel = false,
  variant = "default",
  centerTabButton,
  trailingButton,
}: MobileBottomTabBarProps) {
  const tapScale = useLiquidTapScale();
  const [ripples, setRipples] = useState<Record<string, { id: number; x: number; y: number }[]>>({});
  const [indicator, setIndicator] = useState({ left: 0, top: 0, width: 0, height: 0 });
  const tabsContainerRef = useRef<HTMLDivElement>(null);
  const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
  const fluidity = useGlassFluidity();

  const createRipple = useCallback((tabId: string, e: MouseEvent<HTMLButtonElement>) => {
    const rect = e.currentTarget.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;
    const id = Date.now() + Math.random();
    setRipples((prev) => ({
      ...prev,
      [tabId]: [...(prev[tabId] || []), { id, x, y }],
    }));
    setTimeout(() => {
      setRipples((prev) => ({
        ...prev,
        [tabId]: (prev[tabId] || []).filter((r) => r.id !== id),
      }));
    }, 900);
  }, []);

  const measureIndicator = useCallback(() => {
    const container = tabsContainerRef.current;
    const activeButton = tabRefs.current[activeTab] || Object.values(tabRefs.current)[0];
    if (!container || !activeButton) return;
    const containerRect = container.getBoundingClientRect();
    const buttonRect = activeButton.getBoundingClientRect();
    setIndicator({
      left: buttonRect.left - containerRect.left,
      top: buttonRect.top - containerRect.top,
      width: buttonRect.width,
      height: buttonRect.height,
    });
  }, [activeTab, variant]);

  useLayoutEffect(() => {
    measureIndicator();
  }, [measureIndicator]);

  useEffect(() => {
    window.addEventListener("resize", measureIndicator);
    return () => window.removeEventListener("resize", measureIndicator);
  }, [measureIndicator]);

  const containerClasses: Record<MobileBottomTabVariant, string> = {
    default:
      "fixed bottom-0 left-0 right-0 z-40 px-2 pb-2 pt-1 glass-blur-xl glass-surface-dark glass-border-t border-t-white/10",
    pill:
      "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 px-2 py-2 glass-blur-xl glass-surface-strong glass-border rounded-[2rem] shadow-2xl",
    floating:
      "fixed bottom-6 left-1/2 -translate-x-1/2 z-40 px-3 py-2 glass-blur-xl glass-surface glass-border rounded-[2rem] shadow-2xl shadow-black/20",
    "ios26-fluid":
      "fixed bottom-2 left-2 right-2 z-40 px-3 py-2 glass-blur-xl glass-surface-strong glass-border rounded-[2.5rem] shadow-2xl",
    "ios26-chrome":
      "fixed bottom-0 left-0 right-0 z-40 px-2 pb-3 pt-2 glass-blur-xl glass-surface-chrome glass-border-t border-t-white/10 rounded-t-[2rem]",
    "ios26-glow":
      "fixed bottom-3 left-3 right-3 z-40 px-3 py-2 glass-blur-xl glass-surface-strong glass-border rounded-[2.5rem] shadow-[0_0_40px_rgba(59,130,246,0.15)]",
    "ios26-dock":
      "fixed bottom-3 left-1/2 -translate-x-1/2 z-40 px-2 py-2 glass-blur-xl glass-surface-strong glass-border rounded-[2rem] shadow-2xl shadow-black/20",
    "ios26-super-pill":
      "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 px-2 py-2 glass-blur-xl glass-surface-strong glass-border rounded-[2.5rem] shadow-2xl shadow-black/20",
  };

  const isPillLike =
    variant === "pill" ||
    variant === "floating" ||
    variant === "ios26-fluid" ||
    variant === "ios26-glow" ||
    variant === "ios26-dock" ||
    variant === "ios26-super-pill";

  const isSuperPill = variant === "ios26-super-pill";

  const widthClass =
    variant === "ios26-dock"
      ? "max-w-xs"
      : isSuperPill
        ? "max-w-md"
        : isPillLike
          ? "max-w-sm"
          : "max-w-lg";

  const renderTabs = (tabList: TabItem[]) =>
    tabList.map((tab) => (
      <TabButton
        key={tab.id}
        tab={tab}
        isActive={activeTab === tab.id}
        showLabel={showLabels}
        hideActiveLabel={hideActiveLabel}
        variant={variant}
        ripples={ripples[tab.id] || []}
        onRipple={(e) => createRipple(tab.id, e)}
        onClick={() => onChange?.(tab.id)}
        fluidity={fluidity}
        buttonRef={(el) => {
          tabRefs.current[tab.id] = el;
        }}
      />
    ));

  const trailingFill = useGlassSurface({ variant: "fill", opacity: 0.1 });
  const trailingGlow = useGlassSurface({ variant: "fill", opacity: 0.2 });

  const TrailingButton = trailingButton ? (
    <motion.button
      whileTap={{ scale: tapScale }}
      onClick={trailingButton.onClick}
      className="relative flex flex-col items-center justify-center"
    >
      <div
        className="relative flex h-14 w-14 items-center justify-center rounded-full overflow-hidden glass-blur-xl glass-surface-strong glass-border"
        style={{
          boxShadow:
            "inset 0 1px 0 rgba(255,255,255,0.25), inset 0 -1px 0 rgba(0,0,0,0.08), 0 8px 32px rgba(0,0,0,0.18)",
        }}
      >
        <div className="absolute inset-0" style={{ background: trailingFill.style.background }} />
        <GlassTopHighlight className="inset-x-2 top-0.5" opacity={0.45} />
        <div className="pointer-events-none absolute -top-3 -right-3 h-8 w-8 rounded-full blur-lg" style={{ background: trailingGlow.style.background }} />
        <span className="relative z-10 text-[var(--lg-text)]">{trailingButton.icon}</span>
      </div>
      {showLabels && trailingButton.label && (
        <span className="text-[10px] font-medium text-[var(--lg-text-muted)] mt-1">{trailingButton.label}</span>
      )}
    </motion.button>
  ) : null;

  if (centerTabButton) {
    const midIndex = Math.floor(tabs.length / 2);
    const leftTabs = tabs.slice(0, midIndex);
    const rightTabs = tabs.slice(midIndex);

    return (
      <div className={cn(containerClasses[variant], className)}>
        <TopHighlight variant={variant} />
        <div ref={tabsContainerRef} className={cn("relative flex items-center justify-around", widthClass, "mx-auto")}>
          <ActiveIndicator layout={indicator} variant={variant} fluidity={fluidity} />
          {renderTabs(leftTabs)}

          <motion.button
            whileTap={{ scale: tapScale }}
            onClick={centerTabButton.onClick}
            className="flex flex-col items-center -mt-5 relative"
          >
            <CenterTabButton icon={centerTabButton.icon} />
            {showLabels && (
              <span className="text-[10px] font-medium text-liquid-blue mt-1">{centerTabButton.label}</span>
            )}
          </motion.button>

          {renderTabs(rightTabs)}
        </div>
      </div>
    );
  }

  // Super-pill: main bar and trailing button are two separate glass pieces
  if (isSuperPill && TrailingButton) {
    return (
      <div className={cn("fixed bottom-4 left-1/2 -translate-x-1/2 z-40", className)}>
        <div className="flex items-center gap-3">
          <div
            className="relative px-2 py-2 glass-blur-xl glass-surface-strong glass-border rounded-[2.5rem]"
            style={{
              boxShadow:
                "inset 0 1px 0 rgba(255,255,255,0.22), inset 0 -1px 0 rgba(0,0,0,0.08), 0 8px 32px rgba(0,0,0,0.18)",
            }}
          >
            <TopHighlight variant={variant} />
            <div ref={tabsContainerRef} className={cn("relative flex items-center justify-around", widthClass)}>
              <ActiveIndicator layout={indicator} variant={variant} fluidity={fluidity} />
              {renderTabs(tabs)}
            </div>
          </div>
          {TrailingButton}
        </div>
      </div>
    );
  }

  return (
    <div className={cn(containerClasses[variant], className)}>
      <TopHighlight variant={variant} />
      <div ref={tabsContainerRef} className={cn("relative flex items-center justify-around", widthClass, "mx-auto")}>
        <ActiveIndicator layout={indicator} variant={variant} fluidity={fluidity} />
        {renderTabs(tabs)}
      </div>
    </div>
  );
}

function TopHighlight({ variant }: { variant: MobileBottomTabVariant }) {
  if (
    variant === "pill" ||
    variant === "floating" ||
    variant === "ios26-fluid" ||
    variant === "ios26-glow" ||
    variant === "ios26-dock" ||
    variant === "ios26-super-pill"
  ) {
    return <GlassTopHighlight className="inset-x-4 top-0" opacity={0.4} />;
  }
  return <GlassTopHighlight className="inset-x-0 top-0" opacity={0.2} />;
}

function ActiveIndicator({
  layout,
  variant,
  fluidity,
}: {
  layout: { left: number; top: number; width: number; height: number };
  variant: MobileBottomTabVariant;
  fluidity: number;
}) {
  const thumbSurface = useGlassSurface({ variant: "thumb" });
  const hasBackground =
    variant === "pill" ||
    variant === "ios26-fluid" ||
    variant === "ios26-glow" ||
    variant === "ios26-dock" ||
    variant === "ios26-super-pill";

  const isSuperPill = variant === "ios26-super-pill";
  const radius = isSuperPill ? "1.6rem" : "1rem";

  if (hasBackground) {
    return (
      <motion.div
        layoutId="mobile-tab-bg"
        initial={false}
        animate={{
          left: layout.left,
          top: layout.top,
          width: layout.width,
          height: layout.height,
        }}
        transition={{ type: "spring", stiffness: 300 + fluidity * 3, damping: 30 }}
        className="absolute z-0 pointer-events-none glass-blur-lg overflow-hidden"
        style={{
          borderRadius: radius,
          ...thumbSurface.style,
        }}
      >
        <div
          className="absolute inset-0 glass-reflection mix-blend-soft-light pointer-events-none"
          style={{ borderRadius: radius }}
        />
        <GlassSheen opacity={0.18} />
        <div className="pointer-events-none absolute inset-x-2 top-0.5 h-px bg-[var(--lg-border)] rounded-full" />
      </motion.div>
    );
  }

  // default / floating / ios26-chrome: a glowing top-line indicator
  return (
    <motion.div
      layoutId="mobile-tab-line"
      initial={false}
      animate={{
        left: layout.left + 8,
        top: layout.top + 4,
        width: Math.max(20, layout.width - 16),
        height: 6,
      }}
      transition={{ type: "spring", stiffness: 300 + fluidity * 3, damping: 30 }}
      className="absolute z-0 pointer-events-none glass-blur-lg overflow-hidden rounded-full"
      style={thumbSurface.style}
    >
      <div className="absolute inset-0 rounded-full glass-reflection mix-blend-soft-light pointer-events-none" />
      <GlassSheen opacity={0.18} />
    </motion.div>
  );
}

function CenterTabButton({ icon }: { icon: ReactNode }) {
  const centerFill = useGlassSurface({ variant: "fill", opacity: 0.1 });
  const centerGlow = useGlassSurface({ variant: "fill", opacity: 0.2 });

  return (
    <div className="relative flex h-14 w-14 items-center justify-center rounded-2xl overflow-hidden glass-blur-lg glass-surface-strong glass-border glass-highlight-strong">
      {/* Fluid chrome gradient */}
      <div className="absolute inset-0 bg-gradient-to-br from-liquid-blue/40 via-liquid-purple/30 to-liquid-pink/20" />
      <div className="absolute inset-0" style={{ background: centerFill.style.background }} />
      {/* Inner reflection */}
      <GlassTopHighlight className="inset-x-2 top-0.5" opacity={0.4} />
      <div className="pointer-events-none absolute -top-4 -right-4 h-10 w-10 rounded-full blur-lg" style={{ background: centerGlow.style.background }} />
      <span className="relative z-10 text-white">{icon}</span>
    </div>
  );
}

function TabButton({
  tab,
  isActive,
  showLabel,
  hideActiveLabel,
  variant,
  ripples,
  onRipple,
  onClick,
  fluidity,
  buttonRef,
}: {
  tab: TabItem;
  isActive: boolean;
  showLabel: boolean;
  hideActiveLabel: boolean;
  variant: MobileBottomTabVariant;
  ripples: { id: number; x: number; y: number }[];
  onRipple: (e: MouseEvent<HTMLButtonElement>) => void;
  onClick?: () => void;
  fluidity: number;
  buttonRef?: React.Ref<HTMLButtonElement>;
}) {
  const isFluid = variant === "ios26-fluid" || variant === "ios26-glow" || variant === "ios26-dock";
  const isPill = variant === "pill" || variant === "ios26-dock";
  const isSuperPill = variant === "ios26-super-pill";
  const { isDark } = useTheme();

  return (
    <motion.button
      ref={buttonRef}
      whileTap={{ scale: isFluid || isSuperPill ? 0.92 : 0.85 }}
      onClick={(e) => {
        onRipple(e);
        tab.onClick?.();
        onClick?.();
      }}
      className={cn(
        "relative flex flex-col items-center gap-0.5 overflow-hidden",
        isPill && "rounded-xl py-1.5 px-3",
        isFluid && "rounded-2xl py-1.5 px-4",
        isSuperPill && "rounded-[1.6rem] py-2 px-5",
        !isPill && !isFluid && !isSuperPill && "rounded-xl px-5 py-2"
      )}
    >
      <div className="relative z-10">
        <motion.div
          animate={{
            scale: isActive ? 1.12 : 1,
            y: isActive ? -1 : 0,
          }}
          transition={{ type: "spring", stiffness: 300 + fluidity * 3, damping: 25 }}
          className={cn(
            "transition-colors duration-200",
            isActive
              ? isFluid || isSuperPill
                ? "text-[var(--lg-text)] drop-shadow-[0_0_6px_rgba(255,255,255,0.4)]"
                : "text-liquid-blue"
              : isDark
                ? "text-[var(--lg-text-muted)]"
                : "text-black/40"
          )}
        >
          {isActive && tab.activeIcon ? tab.activeIcon : tab.icon}
        </motion.div>
      </div>

      {showLabel && !hideActiveLabel && (
        <motion.span
          animate={{
            opacity: isActive ? 1 : 0.6,
            y: isActive ? 0 : 1,
          }}
          className={cn(
            "text-[10px] font-medium transition-colors duration-200 relative z-10",
            isActive
              ? isFluid || isSuperPill
                ? "text-[var(--lg-text)]"
                : "text-liquid-blue"
              : isDark
                ? "text-[var(--lg-text-muted)]"
                : "text-black/40"
          )}
        >
          {tab.label}
        </motion.span>
      )}

      {/* Fluid press ripple */}
      <AnimatePresence>
        {ripples.map((ripple) => (
          <motion.span
            key={ripple.id}
            initial={{
              scale: 0,
              opacity: 0.5,
              borderRadius: "45% 55% 50% 50% / 55% 45% 50% 50%",
            }}
            animate={{
              scale: 2.2,
              opacity: 0,
              borderRadius: "50%",
            }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] }}
            className="absolute pointer-events-none z-0"
            style={{
              left: ripple.x,
              top: ripple.y,
              width: 40,
              height: 40,
              marginLeft: -20,
              marginTop: -20,
              background: "radial-gradient(circle, rgba(255,255,255,0.25) 0%, transparent 70%)",
            }}
          />
        ))}
      </AnimatePresence>
    </motion.button>
  );
}

Open in interactive docs

MobileContextPreview

Category: Overlays, Menus & Tooltips

Context menu preview modal.

Props

PropTypeRequiredDescription
children ReactNode Yes Child React nodes rendered inside the component.
actions ContextAction[] Yes -
previewContent ReactNode No -
className string No Additional Tailwind CSS classes.

Usage

<MobileContextPreview />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useRef, useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { GlassTopHighlight } from "./GlassTopHighlight";
import {
  useLiquidOverlayVariants,
  useLiquidTransition,
  useLiquidTapScale,
  useGlassOverlayRootStyle,
} from "./useLiquidMotion";

interface ContextAction {
  id: string;
  title: string;
  icon: ReactNode;
  destructive?: boolean;
  onClick?: () => void;
}

interface MobileContextPreviewProps {
  children: ReactNode;
  actions: ContextAction[];
  previewContent?: ReactNode;
  className?: string;
}

export function MobileContextPreview({
  children,
  actions,
  previewContent,
  className,
}: MobileContextPreviewProps) {
  const [isOpen, setIsOpen] = useState(false);
  const overlayVariants = useLiquidOverlayVariants();
  const transition = useLiquidTransition();
  const tapScale = useLiquidTapScale();
  const overlayRef = useGlassOverlayRootStyle(isOpen);
  const [childRect, setChildRect] = useState<DOMRect | null>(null);
  const ref = useRef<HTMLDivElement>(null);
  const childRef = useRef<HTMLDivElement>(null);

  const handleContextMenu = (e: React.MouseEvent) => {
    e.preventDefault();
    const rect = childRef.current?.getBoundingClientRect();
    if (rect) {
      setChildRect(rect);
      setIsOpen(true);
    }
  };

  useEffect(() => {
    if (!isOpen) return;
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") setIsOpen(false);
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [isOpen]);

  const overlay = (
    <AnimatePresence>
      {isOpen && childRect && (
        <motion.div
          initial={{ opacity: 1 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.15 }}
          ref={overlayRef}
          className="fixed inset-0 z-[80] flex items-center justify-center"
        >
          <div
            className="glass-backdrop-subtle"
            onClick={() => setIsOpen(false)}
          />

          {/* Preview */}
          <motion.div
            {...overlayVariants}
            transition={transition}
            className="relative w-full max-w-xs mx-auto"
          >
            <motion.div
              className={cn(
                "relative rounded-2xl overflow-hidden mb-4",
                "glass-blur-xl glass-surface glass-border glass-highlight-strong",
                previewContent ? "p-4" : "h-32 bg-gradient-to-br from-liquid-blue/20 to-liquid-purple/20"
              )}
            >
              <GlassTopHighlight className="inset-x-0 top-0" opacity={0.25} />
              {previewContent || (
                <div className="flex h-full items-center justify-center text-[var(--lg-text-muted)] text-sm">
                  Preview
                </div>
              )}
            </motion.div>

            {/* Actions */}
            <motion.div
              initial={{ y: 20, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              transition={useLiquidTransition({ delay: 0.1 })}
              className={cn(
                "rounded-2xl overflow-hidden",
                "glass-blur-xl glass-surface glass-border glass-highlight"
              )}
            >
              <GlassTopHighlight className="inset-x-0 top-0" opacity={0.2} />
              {actions.map((action, i) => (
                <motion.button
                  key={action.id}
                  whileTap={{ scale: tapScale }}
                  onClick={() => {
                    action.onClick?.();
                    setIsOpen(false);
                  }}
                  className={cn(
                    "flex w-full items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-[var(--lg-border-subtle)]",
                    i < actions.length - 1 && "border-b border-[var(--lg-border-subtle)]",
                    action.destructive ? "text-liquid-rose" : "text-[var(--lg-text-secondary)]"
                  )}
                >
                  <span className={cn(
                    action.destructive ? "text-liquid-rose" : "text-[var(--lg-text-muted)]"
                  )}>
                    {action.icon}
                  </span>
                  <span className="text-sm">{action.title}</span>
                </motion.button>
              ))}
            </motion.div>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );

  return (
    <div ref={ref} className={cn("relative", className)}>
      <div ref={childRef} onContextMenu={handleContextMenu}>
        {children}
      </div>
      {createPortal(overlay, document.body)}
    </div>
  );
}

Open in interactive docs

MobileFloatingAction

Category: Layout & Surfaces

Liquid Glass FloatingAction component.

Props

PropTypeRequiredDescription
className string No Additional Tailwind CSS classes.
icon ReactNode No Icon element to display.
expandedIcon ReactNode No -
actions FabAction[] No -
onClick () => void No Callback when the element is clicked.
position "bottom-right" | "bottom-left" No -
color string No -
variant "chrome" | "colored" | "ghost" | "glow" No Visual variant to use.

Usage

<MobileFloatingAction />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, type ReactNode } from "react";
import { Plus, X } from "lucide-react";
import { useLiquidPress } from "./useLiquidPress";
import { LiquidGlassPressSplash } from "./LiquidGlassPressSplash";
import { useGlassSurface } from "./useGlassSurface";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { GlassSheen } from "./GlassSheen";
import { useLiquidTapScale, useLiquidTransition } from "./useLiquidMotion";

interface FabAction {
  id: string;
  icon: ReactNode;
  label: string;
  onClick: () => void;
}

interface MobileFloatingActionButtonProps {
  className?: string;
  icon?: ReactNode;
  expandedIcon?: ReactNode;
  actions?: FabAction[];
  onClick?: () => void;
  position?: "bottom-right" | "bottom-left";
  color?: string;
  variant?: "chrome" | "colored" | "ghost" | "glow";
}

export function MobileFloatingActionButton({
  className,
  icon = <Plus size={24} />,
  expandedIcon = <X size={24} />,
  actions,
  onClick,
  position = "bottom-right",
  color = "from-liquid-blue to-liquid-purple",
  variant = "chrome",
}: MobileFloatingActionButtonProps) {
  const tapScale = useLiquidTapScale();
  const scaleTransition = useLiquidTransition();
  const chromeFill = useGlassSurface({ variant: "fill", opacity: 0.1 });
  const chromeGlow = useGlassSurface({ variant: "fill", opacity: 0.15 });
  const coloredGlow = useGlassSurface({ variant: "fill", opacity: 0.2 });
  const [isOpen, setIsOpen] = useState(false);
  const { state: press, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel } =
    useLiquidPress<HTMLButtonElement>();

  const toggle = () => {
    setIsOpen(!isOpen);
    if (!isOpen) onClick?.();
  };

  const positionClass =
    position === "bottom-right" ? "bottom-20 right-4" : "bottom-20 left-4";

  const isColored = variant === "colored" || variant === "glow";
  const isGlow = variant === "glow";
  const isGhost = variant === "ghost";

  const mainClasses = cn(
    "relative flex h-14 w-14 items-center justify-center rounded-2xl overflow-hidden isolate",
    isGhost
      ? "glass-blur-sm glass-surface glass-border text-[var(--lg-text-secondary)] hover:text-[var(--lg-text)]"
      : isColored
        ? cn("bg-gradient-to-br", color, "text-white")
        : "glass-blur-xl glass-surface-strong glass-border glass-highlight-strong text-[var(--lg-text)]",
    isGlow && "shadow-[0_0_30px_rgba(59,130,246,0.35)]",
    !isGhost && !isColored && "shadow-lg shadow-black/20"
  );

  return (
    <div
      className={cn(
        "fixed z-40 flex flex-col items-end gap-3",
        positionClass,
        className
      )}
    >
      <AnimatePresence>
        {isOpen && actions && (
          <div
            className={cn(
              "absolute bottom-full mb-3 flex flex-col gap-3 z-50 pointer-events-auto",
              position === "bottom-right" ? "right-0 items-end" : "left-0 items-start"
            )}
          >
            {actions.map((action, i) => (
              <ActionButton
                key={action.id}
                action={action}
                index={i}
                onClose={() => setIsOpen(false)}
              />
            ))}
          </div>
        )}
      </AnimatePresence>

      <motion.button
        whileTap={{ scale: tapScale }}
        transition={{
          scale: scaleTransition,
          rotate: { duration: 0.2, ease: "easeOut" },
        }}
        onClick={toggle}
        onPointerDown={onPointerDown}
        onPointerUp={onPointerUp}
        onPointerLeave={onPointerLeave}
        onPointerCancel={onPointerCancel}
        animate={{ rotate: isOpen ? 45 : 0 }}
        className={mainClasses}
      >
        {/* Chrome liquid-glass overlays */}
        {!isGhost && !isColored && (
          <>
            <div className="absolute inset-0" style={{ background: chromeFill.style.background }} />
            <GlassTopHighlight className="inset-x-3 top-0.5 z-10" opacity={0.45} />
            <div className="pointer-events-none absolute -top-4 -right-4 h-12 w-12 rounded-full blur-xl" style={{ background: chromeGlow.style.background }} />
            <GlassSheen opacity={0.15} />
          </>
        )}
        {isColored && (
          <>
            <GlassTopHighlight className="inset-x-3 top-0.5 z-10" opacity={0.4} />
            <div className="pointer-events-none absolute -top-3 -right-3 h-10 w-10 rounded-full blur-lg" style={{ background: coloredGlow.style.background }} />
          </>
        )}

        <LiquidGlassPressSplash press={press} size={160} duration={0.25} />
        <span className="relative z-10">{isOpen ? expandedIcon : icon}</span>
      </motion.button>
    </div>
  );
}

function ActionButton({
  action,
  index,
  onClose,
}: {
  action: FabAction;
  index: number;
  onClose: () => void;
}) {
  const actionGlow = useGlassSurface({ variant: "fill", opacity: 0.1 });
  const actionTapScale = useLiquidTapScale();
  const { state: press, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel } =
    useLiquidPress<HTMLButtonElement>();

  return (
    <motion.div
      initial={{ opacity: 0, scale: 0.5, y: 10 }}
      animate={{ opacity: 1, scale: 1, y: 0 }}
      exit={{ opacity: 0, scale: 0.5, y: 10 }}
      transition={useLiquidTransition({ delay: index * 0.03 })}
      className="flex items-center gap-2"
    >
      <span className="px-2 py-1 rounded-lg glass-blur-sm glass-surface glass-border text-xs text-[var(--lg-text-secondary)] whitespace-nowrap">
        {action.label}
      </span>
      <motion.button
        whileTap={{ scale: actionTapScale }}
        onPointerDown={onPointerDown}
        onPointerUp={onPointerUp}
        onPointerLeave={onPointerLeave}
        onPointerCancel={onPointerCancel}
        onClick={() => {
          action.onClick();
          onClose();
        }}
        className="relative flex h-10 w-10 items-center justify-center rounded-full overflow-hidden glass-blur glass-surface-strong glass-border glass-highlight text-[var(--lg-text-secondary)]"
      >
        <GlassTopHighlight className="inset-x-2 top-0.5" opacity={0.25} />
        <div className="pointer-events-none absolute -top-2 -right-2 h-6 w-6 rounded-full blur-md" style={{ background: actionGlow.style.background }} />
        <LiquidGlassPressSplash press={press} size={90} duration={0.3} />
        {action.icon}
      </motion.button>
    </motion.div>
  );
}

Open in interactive docs

MobilePageIndicator

Category: Media & Content

Dots, line, or fraction page indicator.

Props

PropTypeRequiredDescription
currentPage number Yes -
totalPages number Yes -
className string No Additional Tailwind CSS classes.
variant "dots" | "line" | "fraction" No Visual variant to use.
activeColor string No -
inactiveColor string No -

Usage

<MobilePageIndicator />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { useLiquidTransition } from "./useLiquidMotion";

interface MobilePageIndicatorProps {
  currentPage: number;
  totalPages: number;
  className?: string;
  variant?: "dots" | "line" | "fraction";
  activeColor?: string;
  inactiveColor?: string;
}

export function MobilePageIndicator({
  currentPage,
  totalPages,
  className,
  variant = "dots",
  activeColor = "bg-white",
  inactiveColor = "bg-white/20",
}: MobilePageIndicatorProps) {
  const transition = useLiquidTransition();
  if (variant === "line") {
    return (
      <div className={cn("flex gap-1", className)}>
        {Array.from({ length: totalPages }).map((_, i) => (
          <motion.div
            key={i}
            animate={{
              width: i === currentPage ? 24 : 6,
              opacity: i === currentPage ? 1 : 0.4,
            }}
            transition={transition}
            className={cn("h-1.5 rounded-full", i === currentPage ? activeColor : inactiveColor)}
          />
        ))}
      </div>
    );
  }

  if (variant === "fraction") {
    return (
      <span className={cn(
        "text-sm font-medium tabular-nums",
        className
      )}>
        <span className="text-[var(--lg-text-secondary)]">{currentPage + 1}</span>
        <span className="text-[var(--lg-text-muted)] mx-1">/</span>
        <span className="text-[var(--lg-text-muted)]">{totalPages}</span>
      </span>
    );
  }

  return (
    <div className={cn("flex items-center gap-1.5", className)}>
      {Array.from({ length: totalPages }).map((_, i) => (
        <motion.button
          key={i}
          animate={{
            scale: i === currentPage ? 1.2 : 1,
            width: i === currentPage ? 20 : 8,
          }}
          transition={transition}
          className={cn(
            "h-2 rounded-full transition-colors",
            i === currentPage ? activeColor : inactiveColor
          )}
        />
      ))}
    </div>
  );
}

Open in interactive docs

MobilePermissionDialog

Category: Modals, Drawers & Sheets

Permission request sheet with toggles.

Props

PropTypeRequiredDescription
isOpen boolean Yes Controlled open state.
onClose () => void Yes Callback when the component requests to close.
title string Yes Title text.
message string Yes Message content.
permissions Permission[] Yes -
onGrantAll () => void No -
className string No Additional Tailwind CSS classes.

Usage

<MobilePermissionDialog />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
import { LiquidGlassToggle } from "./LiquidGlassToggle";
import { useGlassSurface } from "./useGlassSurface";
import {
  useLiquidSlideVariants,
  useLiquidTransition,
  useLiquidTapScale,
  useGlassOverlayRootStyle,
} from "./useLiquidMotion";

interface Permission {
  id: string;
  title: string;
  description: string;
  icon: React.ReactNode;
  granted?: boolean;
}

interface MobilePermissionDialogProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  message: string;
  permissions: Permission[];
  onGrantAll?: () => void;
  className?: string;
}

export function MobilePermissionDialog({
  isOpen,
  onClose,
  title,
  message,
  permissions,
  onGrantAll,
  className,
}: MobilePermissionDialogProps) {
  const slideVariants = useLiquidSlideVariants("bottom", { stiff: true });
  const transition = useLiquidTransition({ stiff: true });
  const tapScale = useLiquidTapScale();
  const overlayRef = useGlassOverlayRootStyle(isOpen);
  const handleFill = useGlassSurface({ variant: "fill", opacity: 0.2 });
  const [permMap, setPermMap] = useState<Record<string, boolean>>(
    Object.fromEntries(permissions.map((p) => [p.id, p.granted ?? false]))
  );

  const togglePerm = (id: string) => {
    setPermMap((prev) => ({ ...prev, [id]: !prev[id] }));
  };

  return (
    <AnimatePresence>
      {isOpen && (
        <div className="fixed inset-0 z-[60] flex items-end justify-center" ref={overlayRef}>
          <motion.div
            initial={{ opacity: 0.2 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
            className="glass-backdrop"
          />

          <motion.div
            {...slideVariants}
            transition={transition}
            className={cn(
              "relative w-full max-w-lg mx-auto mb-2",
              "glass-blur-xl glass-surface glass-border",
              "rounded-t-3xl overflow-hidden",
              className
            )}
          >
            <div className="flex justify-center pt-3 pb-1">
              <div className="w-10 h-1 rounded-full" style={{ background: handleFill.style.background }} />
            </div>

            <div className="px-6 py-4">
              <h3 className="text-lg font-semibold text-[var(--lg-text)] mb-2">{title}</h3>
              <p className="text-sm text-[var(--lg-text-muted)] leading-relaxed mb-6">{message}</p>

              <div className="space-y-2 mb-6">
                {permissions.map((perm) => (
                  <motion.button
                    key={perm.id}
                    whileTap={{ scale: tapScale }}
                    onClick={() => togglePerm(perm.id)}
                    className={cn(
                      "flex w-full items-center gap-3 p-3 rounded-xl transition-colors",
                      permMap[perm.id] ? "bg-[var(--lg-border-subtle)]" : "bg-[var(--lg-border-subtle)] hover:bg-[var(--lg-border-subtle)]"
                    )}
                  >
                    <div className={cn(
                      "flex h-10 w-10 items-center justify-center rounded-xl flex-shrink-0",
                      permMap[perm.id] ? "bg-liquid-blue/15" : "bg-[var(--lg-border-subtle)]"
                    )}>
                      <span className={cn(
                        permMap[perm.id] ? "text-liquid-blue" : "text-[var(--lg-text-muted)]"
                      )}>
                        {perm.icon}
                      </span>
                    </div>
                    <div className="flex-1 text-left">
                      <p className={cn(
                        "text-sm font-medium",
                        permMap[perm.id] ? "text-[var(--lg-text-secondary)]" : "text-[var(--lg-text-muted)]"
                      )}>
                        {perm.title}
                      </p>
                      <p className="text-xs text-[var(--lg-text-muted)]">{perm.description}</p>
                    </div>
                    <LiquidGlassToggle
                      checked={permMap[perm.id] ?? false}
                      onChange={() => togglePerm(perm.id)}
                      variant="ios26"
                      size="sm"
                    />
                  </motion.button>
                ))}
              </div>

              <div className="flex gap-3">
                <motion.button
                  whileTap={{ scale: tapScale }}
                  onClick={onClose}
                  className="flex-1 py-3 rounded-xl text-sm font-semibold text-[var(--lg-text-muted)] hover:bg-[var(--lg-border-subtle)] transition-colors"
                >
                  Not Now
                </motion.button>
                <motion.button
                  whileTap={{ scale: tapScale }}
                  onClick={onGrantAll}
                  className="flex-1 py-3 rounded-xl text-sm font-semibold text-white glass-blur-sm bg-liquid-blue/20 border border-liquid-blue/30"
                >
                  Allow All
                </motion.button>
              </div>
            </div>
          </motion.div>
        </div>
      )}
    </AnimatePresence>
  );
}

Open in interactive docs

MobileSegmentedControl

Category: Inputs, Toggles & Pickers

iOS segmented control.

Props

PropTypeRequiredDescription
segments Segment[] Yes -
selected string Yes -
onChange (id: string) => void Yes Callback when the value changes.
className string No Additional Tailwind CSS classes.
size "sm" | "md" No Size preset.
variant "default" | "ios26" No Visual variant to use.

Usage

<MobileSegmentedControl />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { useTheme } from "./ThemeProvider";
import type { ReactNode } from "react";
import { useLiquidTransition } from "./useLiquidMotion";

interface Segment {
  id: string;
  label: ReactNode;
  icon?: ReactNode;
}

interface MobileSegmentedControlProps {
  segments: Segment[];
  selected: string;
  onChange: (id: string) => void;
  className?: string;
  size?: "sm" | "md";
  variant?: "default" | "ios26";
}

export function MobileSegmentedControl({
  segments,
  selected,
  onChange,
  className,
  size = "md",
  variant = "default",
}: MobileSegmentedControlProps) {
  const transition = useLiquidTransition();
  const { isDark } = useTheme();
  const selectedIndex = segments.findIndex((s) => s.id === selected);
  const isIos26 = variant === "ios26";

  return (
    <div
      className={cn(
        "relative inline-flex rounded-xl overflow-hidden",
        isIos26 ? "glass-blur-lg glass-surface-strong glass-border p-1" : "bg-[var(--lg-border-subtle)] border border-[var(--lg-border-subtle)] p-0.5",
        size === "sm" ? "h-8" : "h-10",
        className
      )}
    >
      {/* Animated background pill */}
      <motion.div
        layout
        className={cn(
          "absolute top-1 bottom-1 rounded-lg",
          isIos26
            ? "glass-surface-strong border border-[var(--lg-border-subtle)] shadow-inner"
            : "bg-[var(--lg-border)] border border-[var(--lg-border-subtle)]"
        )}
        style={{
          left: `calc(${100 / segments.length * selectedIndex}% + 4px)`,
          width: `calc(${100 / segments.length}% - 8px)`,
        }}
        transition={transition}
      >
        {isIos26 && <div className="pointer-events-none absolute inset-x-2 top-0.5 h-px bg-[var(--lg-border)] rounded-full" />}
      </motion.div>

      {segments.map((segment) => (
        <button
          key={segment.id}
          onClick={() => onChange(segment.id)}
          className={cn(
            "relative z-10 flex-1 flex items-center justify-center gap-1.5",
            "transition-colors select-none",
            selected === segment.id
              ? isDark ? "text-white font-medium" : "text-black font-medium"
              : isDark ? "text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]" : "text-black/40 hover:text-black/60",
            size === "sm" ? "text-xs" : "text-sm"
          )}
        >
          {segment.icon}
          {segment.label}
        </button>
      ))}
    </div>
  );
}

Open in interactive docs

MobileSlideMenu

Category: Modals, Drawers & Sheets

Slide-in side menu for mobile.

Props

PropTypeRequiredDescription
isOpen boolean Yes Controlled open state.
onClose () => void Yes Callback when the component requests to close.
sections MenuSection[] Yes -
header ReactNode No -
footer ReactNode No -
className string No Additional Tailwind CSS classes.
position "left" | "right" No -
width string No -
variant "default" | "compact" | "floating" No Visual variant to use.

Usage

<MobileSlideMenu />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import type { ReactNode } from "react";
import { X, ChevronRight } from "lucide-react";
import {
  useLiquidSlideVariants,
  useLiquidTransition,
  useLiquidTapScale,
  useGlassOverlayRootStyle,
} from "./useLiquidMotion";

interface MenuSection { title: string; items: MenuItem[]; }
interface MenuItem { id: string; label: string; icon: ReactNode; badge?: number; destructive?: boolean; onClick?: () => void; }

interface MobileSlideMenuProps {
  isOpen: boolean;
  onClose: () => void;
  sections: MenuSection[];
  header?: ReactNode;
  footer?: ReactNode;
  className?: string;
  position?: "left" | "right";
  width?: string;
  variant?: "default" | "compact" | "floating";
}

export function MobileSlideMenu({
  isOpen, onClose, sections, header, footer, className,
  position = "left", width = "280px", variant = "default",
}: MobileSlideMenuProps) {
  const isLeft = position === "left";
  const isFloating = variant === "floating";
  const edgeStiff = !isFloating;
  const slideVariants = useLiquidSlideVariants(position, { stiff: edgeStiff });
  const transition = useLiquidTransition({ stiff: edgeStiff });
  const tapScale = useLiquidTapScale();
  const overlayRef = useGlassOverlayRootStyle(isOpen);

  return (
    <AnimatePresence>
      {isOpen && (
        <div className="fixed inset-0 z-[55]" ref={overlayRef}>
          <motion.div initial={{ opacity: 0.2 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
            onClick={onClose} className="glass-backdrop-overlay" />
          <motion.div
            {...slideVariants}
            transition={transition}
            className={cn(
              "absolute top-0 bottom-0",
              isLeft ? "left-0" : "right-0",
              isFloating ? "m-3 rounded-3xl" : "",
              "glass-blur-xl glass-surface-strong glass-border glass-highlight-strong",
              "flex flex-col overflow-hidden",
              className
            )}
            style={{ width, maxWidth: isFloating ? "calc(85vw - 24px)" : "85vw" }}
          >
            <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
            <div className="pointer-events-none absolute -top-10 -right-10 h-24 w-24 rounded-full bg-[var(--lg-reflection)] blur-2xl" />

            {header || (
              <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--lg-border)]">
                <h3 className="text-base font-semibold text-[var(--lg-text)]">Menu</h3>
                <motion.button whileTap={{ scale: tapScale }} onClick={onClose}
                  className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--lg-border)] text-[var(--lg-text-muted)]">
                  <X size={16} />
                </motion.button>
              </div>
            )}

            <div className="flex-1 overflow-y-auto py-2">
              {sections.map((section, si) => (
                <div key={si}>
                  {section.title && (
                    <div className="px-5 py-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--lg-text-muted)]">
                      {section.title}
                    </div>
                  )}
                  {section.items.map((item) => (
                    <motion.button key={item.id} whileTap={{ scale: tapScale }}
                      onClick={() => { item.onClick?.(); onClose(); }}
                      className={cn(
                        "flex w-full items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-[var(--lg-border)]",
                        item.destructive ? "text-liquid-rose" : "text-[var(--lg-text)]"
                      )}>
                      <span className={item.destructive ? "text-liquid-rose" : "text-[var(--lg-text-muted)]"}>{item.icon}</span>
                      <span className="flex-1 text-sm">{item.label}</span>
                      {!item.destructive && <ChevronRight size={14} className="text-[var(--lg-text-muted)]" />}
                    </motion.button>
                  ))}
                  {si < sections.length - 1 && <div className="mx-5 my-1 h-px bg-[var(--lg-border)]" />}
                </div>
              ))}
            </div>

            {footer && <div className="border-t border-[var(--lg-border)] p-4">{footer}</div>}
          </motion.div>
        </div>
      )}
    </AnimatePresence>
  );
}

Open in interactive docs

MobileSnackbar

Category: Feedback & Status

Bottom snackbar with progress and action.

Props

PropTypeRequiredDescription
message string Yes Message content.
variant "info" | "success" | "error" | "warning" No Visual variant to use.
duration number No -
className string No Additional Tailwind CSS classes.

Usage

<MobileSnackbar />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState, useEffect } from "react";
import { X } from "lucide-react";
import { useGlassSurface } from "./useGlassSurface";
import { GlassTopHighlight } from "./GlassTopHighlight";
import {
  useLiquidSlideVariants,
  useLiquidTransition,
  useGlassOverlayRootStyle,
} from "./useLiquidMotion";


interface MobileSnackbarProps {
  message: string;
  action?: { label: string; onClick: () => void };
  variant?: "info" | "success" | "error" | "warning";
  duration?: number;
  className?: string;
}

export function MobileSnackbar({
  message,
  action,
  variant = "info",
  duration = 4000,
  className,
}: MobileSnackbarProps) {
  const [visible, setVisible] = useState(true);
  const [progress, setProgress] = useState(100);
  const slideVariants = useLiquidSlideVariants("bottom");
  const transition = useLiquidTransition();
  const overlayRef = useGlassOverlayRootStyle(visible);
  const progressFill = useGlassSurface({ variant: "fill", opacity: 0.2 });

  useEffect(() => {
    setVisible(true);
    setProgress(100);

    const start = Date.now();
    const interval = setInterval(() => {
      const elapsed = Date.now() - start;
      const remaining = Math.max(0, 100 - (elapsed / duration) * 100);
      setProgress(remaining);
      if (remaining <= 0) {
        clearInterval(interval);
        setVisible(false);
      }
    }, 50);

    return () => clearInterval(interval);
  }, [duration, message]);

  const variantConfig = {
    info: { bg: "bg-[var(--lg-border)]", icon: "info" },
    success: { bg: "bg-liquid-emerald/15", icon: "success" },
    error: { bg: "bg-liquid-rose/15", icon: "error" },
    warning: { bg: "bg-liquid-amber/15", icon: "warning" },
  };

  const config = variantConfig[variant];

  return (
    <AnimatePresence>
      {visible && (
        <motion.div
          {...slideVariants}
          transition={transition}
          ref={overlayRef}
          className={cn(
            "fixed bottom-4 left-4 right-4 z-50 max-w-lg mx-auto",
            "rounded-2xl overflow-hidden",
            "glass-blur-xl glass-surface glass-border glass-highlight-strong",
            className
          )}
        >
          {/* Top highlight */}
          <GlassTopHighlight className="inset-x-0 top-0" opacity={0.2} />

          <div className="flex items-center gap-3 px-4 py-3">
            <div className={cn("flex-1 flex items-center gap-2.5 min-w-0")}>
              <span className={cn(
                "flex h-7 w-7 items-center justify-center rounded-full flex-shrink-0",
                config.bg
              )}>
                <span className="text-xs font-bold text-[var(--lg-text-secondary)]">
                  {variant[0].toUpperCase()}
                </span>
              </span>
              <p className="text-sm text-[var(--lg-text-secondary)]">{message}</p>
            </div>

            {action && (
              <button
                className="flex-shrink-0 text-sm font-semibold text-liquid-blue"
                onClick={action.onClick}
              >
                {action.label}
              </button>
            )}

            <button
              className="flex-shrink-0 text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]"
              onClick={() => setVisible(false)}
            >
              <X size={16} />
            </button>
          </div>

          {/* Progress bar */}
          <div className="h-0.5 bg-[var(--lg-border-subtle)]">
            <motion.div
              className="h-full"
              style={{ width: `${progress}%`, background: progressFill.style.background }}
              transition={{ ease: "linear", duration: 0.05 }}
            />
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

Open in interactive docs

MobileSplashScreen

Category: Modals, Drawers & Sheets

Onboarding splash screen with slides.

Props

PropTypeRequiredDescription
isOpen boolean Yes Controlled open state.
onClose () => void Yes Callback when the component requests to close.
slides SplashSlide[] Yes -
className string No Additional Tailwind CSS classes.
getStartedText string No -
skipText string No -

Usage

<MobileSplashScreen />

Source

import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
import type { ReactNode } from "react";
import { useGlassSurface } from "./useGlassSurface";
import { useLiquidTransition, useGlassOverlayRootStyle } from "./useLiquidMotion";

interface SplashSlide {
  id: string;
  title: string;
  subtitle: string;
  icon: ReactNode;
  gradient: string;
}

interface MobileSplashScreenProps {
  isOpen: boolean;
  onClose: () => void;
  slides: SplashSlide[];
  className?: string;
  getStartedText?: string;
  skipText?: string;
}

export function MobileSplashScreen({
  isOpen,
  onClose,
  slides,
  className,
  getStartedText = "Get Started",
  skipText = "Skip",
}: MobileSplashScreenProps) {
  const transition = useLiquidTransition();
  const overlayRef = useGlassOverlayRootStyle(isOpen);
  const buttonFill = useGlassSurface({ variant: "fill", opacity: 0.15 });
  const [currentSlide, setCurrentSlide] = useState(0);
  const isLastSlide = currentSlide === slides.length - 1;

  if (!isOpen) return null;

  const slide = slides[currentSlide];

  return (
    <motion.div
      initial={{ opacity: 0.2 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      ref={overlayRef}
      className={cn(
        "fixed inset-0 z-[70] flex flex-col items-center justify-between",
        "bg-[#0a0a0f]",
        className
      )}
    >
      {/* Background gradient */}
      <div className={cn(
        "absolute inset-0 transition-all duration-700",
        slide.gradient
      )}>
        <div className="absolute inset-0 bg-black/40" />
      </div>

      {/* Skip button */}
      <div className="relative z-10 w-full max-w-lg flex justify-end p-6">
        <button
          onClick={onClose}
          className="text-sm font-medium text-white/50 hover:text-white/80 transition-colors"
        >
          {skipText}
        </button>
      </div>

      {/* Content */}
      <div className="relative z-10 flex flex-col items-center text-center px-10 max-w-lg">
        <AnimatePresence mode="wait">
          <motion.div
            key={slide.id}
            initial={{ opacity: 0.2, y: 20, scale: 0.9 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: -20, scale: 0.9 }}
            transition={transition}
            className="flex flex-col items-center"
          >
            {/* Icon */}
            <div className="mb-8 flex h-32 w-32 items-center justify-center rounded-3xl glass-blur-lg glass-surface glass-border glass-highlight-strong">
              <div className="text-white/80 scale-150">{slide.icon}</div>
            </div>

            <h2 className="text-2xl font-bold text-white mb-2">{slide.title}</h2>
            <p className="text-sm text-white/50 leading-relaxed">{slide.subtitle}</p>
          </motion.div>
        </AnimatePresence>
      </div>

      {/* Bottom controls */}
      <div className="relative z-10 w-full max-w-lg flex flex-col items-center gap-6 pb-12 px-8">
        {/* Page indicators */}
        <div className="flex items-center gap-2">
          {slides.map((_, i) => (
            <button
              key={i}
              onClick={() => setCurrentSlide(i)}
              className={cn(
                "h-1.5 rounded-full transition-all duration-300",
                i === currentSlide
                  ? "w-8 bg-white"
                  : "w-1.5 bg-white/30 hover:bg-white/50"
              )}
            />
          ))}
        </div>

        {/* Navigation buttons */}
        <div className="flex w-full gap-3">
          {!isLastSlide && (
            <button
              onClick={() => setCurrentSlide(Math.max(0, currentSlide - 1))}
              className="flex-1 py-3.5 rounded-2xl text-sm font-semibold text-white/50 glass-blur-sm glass-surface glass-border hover:bg-white/10 transition-colors"
            >
              Back
            </button>
          )}
          {isLastSlide ? (
            <button
              onClick={onClose}
              className="flex-1 py-3.5 rounded-2xl text-sm font-semibold text-white glass-blur-sm glass-border glass-highlight"
              style={{ background: buttonFill.style.background }}
            >
              {getStartedText}
            </button>
          ) : (
            <button
              onClick={() => setCurrentSlide(Math.min(slides.length - 1, currentSlide + 1))}
              className="flex-1 py-3.5 rounded-2xl text-sm font-semibold text-white glass-blur-sm glass-border glass-highlight"
              style={{ background: buttonFill.style.background }}
            >
              Next
            </button>
          )}
        </div>
      </div>
    </motion.div>
  );
}

Open in interactive docs

MobileStepper

Category: Inputs, Toggles & Pickers

Plus/minus stepper control.

Props

PropTypeRequiredDescription
value number Yes Current value of the control.
onChange (value: number) => void Yes Callback when the value changes.
min number No -
max number No -
step number No -
className string No Additional Tailwind CSS classes.
label string No Label text.
size "sm" | "md" | "lg" No Size preset.

Usage

<MobileStepper />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { Minus, Plus } from "lucide-react";

interface MobileStepperProps {
  value: number;
  onChange: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  className?: string;
  label?: string;
  size?: "sm" | "md" | "lg";
}

export function MobileStepper({
  value,
  onChange,
  min = 0,
  max = 99,
  step = 1,
  className,
  label,
  size = "md",
}: MobileStepperProps) {
  const sizeClasses = {
    sm: "gap-1.5 px-1.5 py-1",
    md: "gap-2 px-2 py-1.5",
    lg: "gap-3 px-3 py-2",
  };

  const buttonSizes = {
    sm: 24,
    md: 30,
    lg: 36,
  };

  const textSizes = {
    sm: "text-sm",
    md: "text-base",
    lg: "text-lg",
  };

  const decrement = () => onChange(Math.max(min, value - step));
  const increment = () => onChange(Math.min(max, value + step));

  return (
    <div className={cn("inline-flex items-center", className)}>
      {label && <span className="text-sm text-[var(--lg-text-muted)] mr-3">{label}</span>}
      <div
        className={cn(
          "inline-flex items-center rounded-xl",
          "glass-blur-sm glass-surface glass-border",
          sizeClasses[size]
        )}
      >
        {/* Top highlight */}
        <div className="absolute inset-x-1 top-0 h-px bg-[var(--lg-border)] rounded-full" />

        <motion.button
          whileTap={{ scale: 0.85 }}
          onClick={decrement}
          disabled={value <= min}
          className={cn(
            "flex items-center justify-center rounded-lg transition-all",
            "text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]",
            value <= min && "opacity-30 cursor-not-allowed"
          )}
          style={{ width: buttonSizes[size], height: buttonSizes[size] }}
        >
          <Minus size={buttonSizes[size] * 0.35} strokeWidth={2.5} />
        </motion.button>

        <motion.span
          key={value}
          initial={{ scale: 1.15 }}
          animate={{ scale: 1 }}
          className={cn(
            "min-w-[2ch] text-center tabular-nums font-semibold text-[var(--lg-text)]",
            textSizes[size]
          )}
        >
          {value}
        </motion.span>

        <motion.button
          whileTap={{ scale: 0.85 }}
          onClick={increment}
          disabled={value >= max}
          className={cn(
            "flex items-center justify-center rounded-lg transition-all",
            "text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]",
            value >= max && "opacity-30 cursor-not-allowed"
          )}
          style={{ width: buttonSizes[size], height: buttonSizes[size] }}
        >
          <Plus size={buttonSizes[size] * 0.35} strokeWidth={2.5} />
        </motion.button>
      </div>
    </div>
  );
}

Open in interactive docs

MobileSwipeableList

Category: Data Display

Swipeable list items with action buttons.

Props

PropTypeRequiredDescription
items SwipeableItem[] Yes -
className string No Additional Tailwind CSS classes.

Usage

<MobileSwipeableList />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import type { ReactNode } from "react";

interface SwipeableItem {
  id: string;
  content: ReactNode;
  leftActions?: { icon: ReactNode; color: string; onClick: () => void }[];
  rightActions?: { icon: ReactNode; color: string; onClick: () => void }[];
}

interface MobileSwipeableListProps {
  items: SwipeableItem[];
  className?: string;
}

export function MobileSwipeableList({
  items,
  className,
}: MobileSwipeableListProps) {
  const [swipingId, setSwipingId] = useState<string | null>(null);
  const [swipeOffset, setSwipeOffset] = useState(0);
  const startX = useRef(0);
  const actionWidth = 100;

  const handleTouchStart = (id: string, e: React.TouchEvent) => {
    setSwipingId(id);
    startX.current = e.touches[0].clientX;
  };

  const handleTouchMove = (e: React.TouchEvent) => {
    if (!swipingId) return;
    const diff = e.touches[0].clientX - startX.current;
    setSwipeOffset(diff);
  };

  const handleTouchEnd = () => {
    if (!swipingId) return;
    if (Math.abs(swipeOffset) > actionWidth / 2) {
      // Stay open
    } else {
      setSwipeOffset(0);
    }
    setSwipingId(null);
  };

  const closeSwipe = () => setSwipeOffset(0);

  return (
    <div className={cn("space-y-1", className)}>
      {items.map((item) => (
        <div key={item.id} className="relative overflow-hidden rounded-xl">
          {/* Background actions */}
          <div className="absolute inset-0 flex">
            {/* Left actions */}
            <div className="flex items-center">
              {item.leftActions?.map((action, i) => (
                <button
                  key={i}
                  onClick={() => { action.onClick(); closeSwipe(); }}
                  className="flex h-full items-center justify-center px-5"
                  style={{ backgroundColor: action.color }}
                >
                  {action.icon}
                </button>
              ))}
            </div>
            <div className="flex-1" />
            {/* Right actions */}
            <div className="flex items-center flex-row-reverse">
              {item.rightActions?.map((action, i) => (
                <button
                  key={i}
                  onClick={() => { action.onClick(); closeSwipe(); }}
                  className="flex h-full items-center justify-center px-5"
                  style={{ backgroundColor: action.color }}
                >
                  {action.icon}
                </button>
              ))}
            </div>
          </div>

          {/* Foreground card */}
          <motion.div
            onTouchStart={(e) => handleTouchStart(item.id, e)}
            onTouchMove={handleTouchMove}
            onTouchEnd={handleTouchEnd}
            drag="x"
            dragConstraints={{ left: -120, right: 120 }}
            dragElastic={0.8}
            onDragEnd={(_, info) => {
              if (Math.abs(info.offset.x) > 60) {
                setSwipeOffset(info.offset.x > 0 ? 100 : -100);
              } else {
                setSwipeOffset(0);
              }
              setSwipingId(null);
            }}
            className="relative z-10"
          >
            <div className={cn(
              "px-4 py-3 glass-blur-sm glass-surface glass-border"
            )}>
              {item.content}
            </div>
          </motion.div>
        </div>
      ))}
    </div>
  );
}

import { useState, useRef } from "react";

Open in interactive docs

MobileTopNavBar

Category: Navigation

Mobile top navigation bar with 5 layout variants.

Props

PropTypeRequiredDescription
title string No Title text.
subtitle string No -
showBack boolean No -
onBack () => void No -
leftAction ReactNode No -
rightActions ReactNode[] No -
className string No Additional Tailwind CSS classes.
variant "standard" | "large" | "inline" | "search" | "prominent" No Visual variant to use.
translucent boolean No -

Usage

<MobileTopNavBar />

Source

import { cn } from "../../utils/cn";
import { motion } from "framer-motion";
import { ChevronLeft, MoreVertical, Search } from "lucide-react";
import { useState, type ReactNode } from "react";
import { useLiquidPress } from "./useLiquidPress";
import { LiquidGlassPressSplash } from "./LiquidGlassPressSplash";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { GlassSheen } from "./GlassSheen";
import { useLiquidTapScale, useLiquidTransition } from "./useLiquidMotion";

interface MobileTopNavBarProps {
  title?: string;
  subtitle?: string;
  showBack?: boolean;
  onBack?: () => void;
  leftAction?: ReactNode;
  rightActions?: ReactNode[];
  className?: string;
  variant?: "standard" | "large" | "inline" | "search" | "prominent";
  translucent?: boolean;
}

function BackButton({ onBack }: { onBack?: () => void }) {
  const tapScale = useLiquidTapScale();
  const { state: press, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel } =
    useLiquidPress<HTMLButtonElement>();

  return (
    <motion.button
      whileTap={{ scale: tapScale }}
      onClick={onBack}
      onPointerDown={onPointerDown}
      onPointerUp={onPointerUp}
      onPointerLeave={onPointerLeave}
      onPointerCancel={onPointerCancel}
      className="relative flex h-8 w-8 items-center justify-center rounded-xl glass-blur-sm glass-surface glass-border glass-highlight text-[var(--lg-text-secondary)] hover:text-[var(--lg-text)] overflow-hidden"
    >
      <LiquidGlassPressSplash press={press} size={80} />
      <ChevronLeft size={20} strokeWidth={2.5} className="relative z-10" />
    </motion.button>
  );
}

export function MobileTopNavBar({
  title,
  subtitle,
  showBack = true,
  onBack,
  leftAction,
  rightActions,
  className,
  variant = "standard",
  translucent = true,
}: MobileTopNavBarProps) {
  const searchTransition = useLiquidTransition();
  const tapScale = useLiquidTapScale();
  const [searchValue, setSearchValue] = useState("");
  const [searchFocused, setSearchFocused] = useState(false);

  if (variant === "large") {
    return (
      <div className={cn(
        "relative z-40",
        translucent ? "glass-blur-lg glass-surface glass-border" : "bg-[var(--lg-bg)] border-b border-[var(--lg-border)]",
        "rounded-2xl overflow-hidden",
        className
      )}>
        <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
        <div className="pointer-events-none absolute -top-10 -right-10 h-24 w-24 rounded-full bg-[var(--lg-reflection)] blur-2xl" />
        <div className="flex items-center justify-between px-4 h-11">
          <div className="flex items-center min-w-12">
            {leftAction || (showBack && <BackButton onBack={onBack} />)}
          </div>
          <div className="flex items-center gap-1 min-w-12 justify-end">
            {rightActions?.map((action, i) => <span key={i}>{action}</span>)}
          </div>
        </div>
        <div className="px-5 pb-3">
          <h1 className="text-2xl font-bold text-[var(--lg-text)]">{title}</h1>
          {subtitle && <p className="text-xs text-[var(--lg-text-muted)] mt-0.5">{subtitle}</p>}
        </div>
      </div>
    );
  }

  if (variant === "prominent") {
    return (
      <div className={cn(
        "relative z-40",
        translucent ? "glass-blur-xl glass-surface-strong glass-border glass-highlight-strong" : "bg-[var(--lg-bg)]",
        "rounded-2xl overflow-hidden",
        className
      )}>
        <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
        <div className="pointer-events-none absolute -top-10 -right-10 h-24 w-24 rounded-full bg-[var(--lg-reflection)] blur-2xl" />
        <div className="flex items-center justify-between px-4 h-11">
          <div className="flex items-center min-w-12">
            {leftAction || (showBack && <BackButton onBack={onBack} />)}
          </div>
          <div className="flex items-center gap-1 min-w-12 justify-end">
            {rightActions?.map((action, i) => <span key={i}>{action}</span>)}
          </div>
        </div>
        <div className="px-5 pb-4 pt-1 text-center">
          <h1 className="text-xl font-bold text-[var(--lg-text)]">{title}</h1>
          {subtitle && <p className="text-xs text-[var(--lg-text-muted)] mt-1">{subtitle}</p>}
        </div>
      </div>
    );
  }

  if (variant === "inline") {
    return (
      <div className={cn(
        "relative z-40 flex items-center gap-3 px-4 h-11",
        translucent ? "glass-blur glass-surface glass-border" : "bg-[var(--lg-bg)] border-b border-[var(--lg-border)]",
        "rounded-2xl overflow-hidden",
        className
      )}>
        <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
        {leftAction || (showBack && <BackButton onBack={onBack} />)}
        <div className="flex-1 min-w-0">
          <h1 className="text-sm font-semibold text-[var(--lg-text)] truncate">{title}</h1>
        </div>
        <div className="flex items-center gap-1">
          {rightActions?.map((action, i) => <span key={i}>{action}</span>)}
        </div>
      </div>
    );
  }

  if (variant === "search") {
    return (
      <div className={cn(
        "relative z-40",
        translucent ? "glass-blur-lg glass-surface glass-border" : "bg-[var(--lg-bg)]",
        "rounded-2xl overflow-hidden",
        className
      )}>
        <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
        <div className="flex items-center gap-3 px-4 h-12">
          {leftAction || (showBack && <BackButton onBack={onBack} />)}
          <motion.div
            animate={{
              scale: searchFocused ? 1.01 : 1,
              ...(searchFocused && {
                boxShadow:
                  "inset 0 1px 0 var(--lg-highlight-top), inset 0 -1px 0 var(--lg-highlight-bottom), 0 0 24px rgba(255,255,255,0.12)",
              }),
            }}
            transition={searchTransition}
            className={cn(
              "relative flex-1 flex items-center gap-2 px-3 py-1.5 rounded-xl overflow-hidden",
              "glass-blur glass-surface-strong glass-border glass-highlight",
              searchFocused && "ring-2 ring-white/20"
            )}
          >
            {/* Top highlight */}
            <GlassTopHighlight className="inset-x-3 top-0" opacity={0.25} />
            {/* Reflection blob */}
            <div className="pointer-events-none absolute -top-4 -right-4 h-12 w-12 rounded-full glass-reflection blur-xl" />
            {/* Sheen */}
            <GlassSheen opacity={0.12} />
            <Search size={14} className="relative z-10 text-[var(--lg-text-muted)] flex-shrink-0" />
            <input
              type="text"
              value={searchValue}
              onChange={(e) => setSearchValue(e.target.value)}
              onFocus={() => setSearchFocused(true)}
              onBlur={() => setSearchFocused(false)}
              placeholder="Search..."
              className="relative z-10 flex-1 bg-transparent text-sm text-[var(--lg-text)] placeholder-[var(--lg-text-muted)] outline-none"
            />
          </motion.div>
          <div className="flex items-center gap-1">
            {rightActions?.map((action, i) => <span key={i}>{action}</span>)}
          </div>
        </div>
      </div>
    );
  }

  // Standard
  return (
    <div className={cn(
      "relative z-40 flex items-center justify-between px-4 h-12",
      translucent ? "glass-blur-lg glass-surface glass-border" : "bg-[var(--lg-bg)] border-b border-[var(--lg-border)]",
      "rounded-2xl overflow-hidden",
      className
    )}>
      <div className="pointer-events-none absolute inset-x-0 top-0 h-px glass-top-highlight" />
      <div className="pointer-events-none absolute -top-10 -right-10 h-20 w-20 rounded-full bg-[var(--lg-reflection)] blur-2xl" />
      <div className="flex items-center min-w-12">
        {leftAction || (showBack && <BackButton onBack={onBack} />)}
      </div>
      <div className="flex flex-col items-center absolute left-1/2 -translate-x-1/2">
        {title && <h1 className="text-sm font-medium text-[var(--lg-text)]">{title}</h1>}
        {subtitle && <p className="text-[10px] text-[var(--lg-text-muted)]">{subtitle}</p>}
      </div>
      <div className="flex items-center gap-1 min-w-12 justify-end">
        {rightActions?.map((action, i) => <span key={i}>{action}</span>)}
        {!rightActions?.length && (
          <motion.button whileTap={{ scale: tapScale }} className="p-1 text-[var(--lg-text-muted)]"><MoreVertical size={18} /></motion.button>
        )}
      </div>
    </div>
  );
}

Open in interactive docs

ThemeProvider

Category: Theme & Glass Primitives

Wraps your app to provide theme state, glass settings, and persistence.

Props

No props documented.

Usage

<ThemeProvider />

Source

import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
import type { KubeProfile } from "./kube/profiles";
import {
  KubeFilter,
  supportsKubeBackdropFilter,
  kubePropsFromLiquidGlass,
  LIQUID_GLASS_FILTER_ID,
  LIQUID_GLASS_FILTER_LITE_ID,
} from "./kube";

type Theme = "dark" | "light";
export type GlassMode = "glass" | "liquid-glass";

export interface GlassSettings {
  blur: number;
  transparency: number;
  saturation: number;
}

export interface LiquidGlassSettings {
  profile: KubeProfile;
  bezel: number;
  refraction: number;
  thickness: number;
  lightAngle: number;
  specularOpacity: number;
  transparency: number;
  blur: number;
  saturation: number;
}

interface ThemeContextType {
  theme: Theme;
  setTheme: (theme: Theme) => void;
  toggleTheme: () => void;
  isDark: boolean;
  mode: GlassMode;
  setMode: (mode: GlassMode) => void;
  toggleMode: () => void;
  glass: GlassSettings;
  setGlass: (settings: Partial<GlassSettings>) => void;
  resetGlass: () => void;
  liquidGlass: LiquidGlassSettings;
  setLiquidGlass: (settings: Partial<LiquidGlassSettings>) => void;
  resetLiquidGlass: () => void;
}

const defaultGlass: GlassSettings = {
  blur: 17,
  transparency: 10,
  saturation: 100,
};

const defaultLiquidGlass: LiquidGlassSettings = {
  profile: "convex-circle",
  bezel: 9,
  refraction: 90,
  thickness: 120,
  lightAngle: -150,
  specularOpacity: 10,
  transparency: 20,
  blur: 10,
  saturation: 100,
};

const ThemeContext = createContext<ThemeContextType>({
  theme: "dark",
  setTheme: () => {},
  toggleTheme: () => {},
  isDark: true,
  mode: "glass",
  setMode: () => {},
  toggleMode: () => {},
  glass: defaultGlass,
  setGlass: () => {},
  resetGlass: () => {},
  liquidGlass: defaultLiquidGlass,
  setLiquidGlass: () => {},
  resetLiquidGlass: () => {},
});

export function useTheme() {
  return useContext(ThemeContext);
}

export function ThemeProvider({ children, defaultTheme = "dark" }: { children: ReactNode; defaultTheme?: Theme }) {
  const [theme, setTheme] = useState<Theme>(() => {
    if (typeof window !== "undefined") {
      const stored = localStorage.getItem("lg-theme");
      if (stored === "light" || stored === "dark") return stored;
    }
    return defaultTheme;
  });

  const [mode, setModeState] = useState<GlassMode>(() => {
    if (typeof window !== "undefined") {
      const stored = localStorage.getItem("lg-mode");
      if (stored === "glass" || stored === "liquid-glass") return stored;
    }
    return "glass";
  });

  const [glass, setGlassState] = useState<GlassSettings>(() => {
    if (typeof window !== "undefined") {
      const stored = localStorage.getItem("lg-glass");
      if (stored) {
        try {
          const parsed = JSON.parse(stored);
          return { ...defaultGlass, ...parsed };
        } catch {}
      }
    }
    return defaultGlass;
  });

  const [liquidGlass, setLiquidGlassState] = useState<LiquidGlassSettings>(() => {
    if (typeof window !== "undefined") {
      const stored = localStorage.getItem("lg-liquid-glass");
      if (stored) {
        try {
          const parsed = JSON.parse(stored);
          return { ...defaultLiquidGlass, ...parsed };
        } catch {}
      }
    }
    return defaultLiquidGlass;
  });

  useEffect(() => {
    const root = document.documentElement;
    root.setAttribute("data-theme", theme);
    localStorage.setItem("lg-theme", theme);
    if (theme === "dark") {
      root.classList.add("dark");
      root.classList.remove("light");
    } else {
      root.classList.add("light");
      root.classList.remove("dark");
    }
  }, [theme]);

  useEffect(() => {
    const root = document.documentElement;
    root.setAttribute("data-glass-mode", mode);
    localStorage.setItem("lg-mode", mode);
  }, [mode]);

  useEffect(() => {
    const supported = supportsKubeBackdropFilter();
    document.documentElement.setAttribute(
      "data-liquid-glass-supported",
      supported ? "true" : "false"
    );
  }, []);

  useEffect(() => {
    const root = document.documentElement;
    root.style.setProperty("--lg-blur", `${glass.blur}`);
    // In liquid-glass mode the single Transparency slider controls the
    // liquid-glass config. Route that value into --lg-transparency so every
    // glass-surface* utility responds to the same control.
    root.style.setProperty(
      "--lg-transparency",
      mode === "liquid-glass" ? `${liquidGlass.transparency}` : `${glass.transparency}`
    );
    // Saturation is mode-aware so the active slider always drives the
    // backdrop-filter saturate() applied by the glass utilities.
    root.style.setProperty(
      "--lg-saturation",
      mode === "liquid-glass" ? `${liquidGlass.saturation}` : `${glass.saturation}`
    );
    localStorage.setItem("lg-glass", JSON.stringify(glass));
  }, [glass, liquidGlass, mode]);

  useEffect(() => {
    localStorage.setItem("lg-liquid-glass", JSON.stringify(liquidGlass));
  }, [liquidGlass]);

  const toggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
  const toggleMode = () => setModeState((m) => (m === "glass" ? "liquid-glass" : "glass"));

  const setGlass = (settings: Partial<GlassSettings>) => {
    setGlassState((prev) => ({ ...prev, ...settings }));
  };

  const resetGlass = () => setGlassState(defaultGlass);

  const setLiquidGlass = (settings: Partial<LiquidGlassSettings>) => {
    setLiquidGlassState((prev) => ({ ...prev, ...settings }));
  };

  const resetLiquidGlass = () => setLiquidGlassState(defaultLiquidGlass);

  const kubeSupported = supportsKubeBackdropFilter();

  return (
    <ThemeContext.Provider
      value={{
        theme,
        setTheme,
        toggleTheme,
        isDark: theme === "dark",
        mode,
        setMode: setModeState,
        toggleMode,
        glass,
        setGlass,
        resetGlass,
        liquidGlass,
        setLiquidGlass,
        resetLiquidGlass,
      }}
    >
      {children}
      {mode === "liquid-glass" && kubeSupported && (
        <>
          <style>{`
            .glass-blur,
            .glass-blur-sm {
              backdrop-filter: url(#${LIQUID_GLASS_FILTER_LITE_ID}) saturate(calc(var(--lg-saturation) * 3%)) !important;
              -webkit-backdrop-filter: url(#${LIQUID_GLASS_FILTER_LITE_ID}) saturate(calc(var(--lg-saturation) * 3%)) !important;
              contain: layout !important;
            }
            .glass-blur-lg,
            .glass-blur-xl {
              backdrop-filter: url(#${LIQUID_GLASS_FILTER_ID}) saturate(calc(var(--lg-saturation) * 3%)) !important;
              -webkit-backdrop-filter: url(#${LIQUID_GLASS_FILTER_ID}) saturate(calc(var(--lg-saturation) * 3%)) !important;
              contain: layout !important;
            }
          `}</style>
          <KubeFilter
            id={LIQUID_GLASS_FILTER_ID}
            liteId={LIQUID_GLASS_FILTER_LITE_ID}
            width={1}
            height={1}
            normalized
            {...kubePropsFromLiquidGlass(liquidGlass)}
          />
        </>
      )}
    </ThemeContext.Provider>
  );
}

Open in interactive docs

useCustomKubeFilter

Category: Layout & Surfaces

Liquid Glass useCustomKubeFilter component.

Props

PropTypeRequiredDescription
filterUrl string Yes Full-quality filter URL for large surfaces.
liteFilterUrl string Yes Lite filter URL for small/medium surfaces.
Filter ReactNode Yes Render this somewhere in the React tree (sibling or portal).

Usage

<useCustomKubeFilter />

Source

import { useMemo, type ReactNode } from "react";
import { useTheme } from "./ThemeProvider";
import type { LiquidGlassSettings } from "./ThemeProvider";
import {
  KubeFilter,
  useKubeFilterId,
  kubePropsFromLiquidGlass,
} from "./kube";

export interface UseCustomKubeFilterResult {
  /** Full-quality filter URL for large surfaces. */
  filterUrl: string;
  /** Lite filter URL for small/medium surfaces. */
  liteFilterUrl: string;
  /** Render this somewhere in the React tree (sibling or portal). */
  Filter: ReactNode;
}

/**
 * Render a dedicated kube filter for a single component/subtree so it can use
 * liquid-glass settings that differ from the global ThemeProvider config.
 *
 * ⚠️ Performance note: every custom filter is a separate SVG filter graph and
 * its own set of backdrop-filter instances. Use sparingly for hero components
 * or small subtrees. Do not use this for every button/input — that would undo
 * the tiered/shared-filter optimizations.
 */
export function useCustomKubeFilter(
  overrides?: Partial<LiquidGlassSettings>
): UseCustomKubeFilterResult {
  const id = useKubeFilterId();
  const liteId = `${id}-lite`;
  const { liquidGlass } = useTheme();

  const settings = { ...liquidGlass, ...overrides };
  const baseProps = kubePropsFromLiquidGlass(settings);

  const filterUrl = `url(#${id})`;
  const liteFilterUrl = `url(#${liteId})`;

  const Filter = useMemo(
    () => (
      <KubeFilter
        id={id}
        liteId={liteId}
        width={1}
        height={1}
        normalized
        {...baseProps}
      />
    ),
    [id, liteId, baseProps]
  );

  return { filterUrl, liteFilterUrl, Filter };
}

Open in interactive docs