Components
Browse by component
GlassSheen — Reusable specular sheen overlay for glass surfaces.GlassTopHighlight — Reusable top highlight line used across glass components.LiquidGlassAccordion — Collapsible accordion panels.LiquidGlassAlert — Theme-aware alert banners with status variants.LiquidGlassAvatar — Avatar with fallback, status, and ring.LiquidGlassBadge — Small status badge with dot and color variants.LiquidGlassBreadcrumb — Breadcrumb navigation with separators.LiquidGlassButton — Primary button with liquid ripple and glass surface styles.LiquidGlassCalendar — Month calendar with highlighted and disabled dates.LiquidGlassCard — Basic glass card container with variants, padding, and hover lift.LiquidGlassCarousel — Image/content carousel with arrows and indicators.LiquidGlassCheckbox — Glass checkbox with indeterminate support.LiquidGlassChip — Filter/removable chip component.LiquidGlassColorPicker — Color palette picker with custom hex input.LiquidGlassCommandPalette — Cmd+K style searchable command palette.LiquidGlassContextMenu — Right-click context menu portaled to document.body.LiquidGlassControls — Interactive panel to adjust blur, transparency, and saturation.LiquidGlassDock — macOS-style floating dock.LiquidGlassDrawer — Side drawer that slides in from left or right.LiquidGlassEmptyState — Empty state illustration with icon variants.LiquidGlassFileUpload — Drag-and-drop file upload with file list.LiquidGlassFluidCard — Card with a cursor-following liquid refraction glow.LiquidGlassHoverCard — Hover-triggered popover portaled to document.body.LiquidGlassInput — Glass text input with icons, label, and error states.LiquidGlassIos26Button — iOS 26 inspired chrome button with rich glass effects.LiquidGlassKanban — Drag-and-drop kanban board.LiquidGlassMenubar — Dropdown menubar for desktop-style menus.LiquidGlassModal — Centered modal dialog with glass blur.LiquidGlassMusicPlayer — Glass music player card.LiquidGlassNavigation — Horizontal glass navigation bar with animated active indicator.LiquidGlassNotificationDropdown — Bell dropdown panel portaled to document.body.LiquidGlassPagination — Pagination controls with ellipsis handling.LiquidGlassPressSplash — Animated press splash used by buttons and chips.LiquidGlassProgress — Progress bar with default, gradient, and segmented variants.LiquidGlassRadioGroup — Radio group with glass selection indicators.LiquidGlassRating — Star rating with half-star support.LiquidGlassResizable — Resizable panel with drag handle.LiquidGlassScrollArea — Custom scrollbar scroll area.LiquidGlassSelect — Dropdown select portaled to document.body for working blur.LiquidGlassSheet — Bottom sheet with default, compact, full, inset, and detached variants.LiquidGlassSkeleton — Loading placeholder with shimmer animation.LiquidGlassSlider — Range slider with a stretching liquid-glass thumb.LiquidGlassStatCard — Grid of stat cards with change indicators.LiquidGlassStepper — Horizontal or vertical stepper.LiquidGlassSurface — Liquid Glass Surface component.LiquidGlassTabBar — Tab bar with default, pills, and underline variants.LiquidGlassTable — Sortable data table with glass styling.LiquidGlassTimeline — Vertical timeline component.LiquidGlassToast — Stacked toast notifications with progress bars.LiquidGlassToggle — Fluid or iOS 26 style toggle switch.LiquidGlassTooltip — Hover tooltip with directional positioning.LiquidGlassWeatherWidget — Weather widget with hourly forecast.MobileActionSheet — Bottom action sheet with default, grouped, minimal, and grid variants.MobileAlertDialog — Mobile alert dialog with options.MobileAppRating — App rating modal with star selection.MobileBottomTabBar — iOS/Android bottom tab bar with 8 visual variants.MobileContextPreview — Context menu preview modal.MobileFloatingAction — Liquid Glass FloatingAction component.MobilePageIndicator — Dots, line, or fraction page indicator.MobilePermissionDialog — Permission request sheet with toggles.MobileSearchBar — iOS-style search bar with cancel button.MobileSegmentedControl — iOS segmented control.MobileSlideMenu — Slide-in side menu for mobile.MobileSnackbar — Bottom snackbar with progress and action.MobileSplashScreen — Onboarding splash screen with slides.MobileStepper — Plus/minus stepper control.MobileSwipeableList — Swipeable list items with action buttons.MobileTopNavBar — Mobile top navigation bar with 5 layout variants.ThemeProvider — Wraps your app to provide theme state, glass settings, and persistence.useCustomKubeFilter — Liquid Glass useCustomKubeFilter component.
GlassSheen
Category: Theme & Glass Primitives
Reusable specular sheen overlay for glass surfaces.
Props
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
LiquidGlassCarousel
Category: Media & Content
Image/content carousel with arrows and indicators.
Props
Prop Type Required Description
items
CarouselItem[]
Yes
-
className
string
No
Additional Tailwind CSS classes.
autoPlay
boolean
No
-
interval
number
No
-
showIndicators
boolean
No
-
showArrows
boolean
No
-
Usage
<LiquidGlassCarousel />
Source
import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState, useCallback, useEffect } from "react";
import { useLiquidTapScale, useLiquidTransition } from "./useLiquidMotion";
interface CarouselItem {
id: string;
content: React.ReactNode;
}
interface LiquidGlassCarouselProps {
items: CarouselItem[];
className?: string;
autoPlay?: boolean;
interval?: number;
showIndicators?: boolean;
showArrows?: boolean;
}
export function LiquidGlassCarousel({
items,
className,
autoPlay = false,
interval = 4000,
showIndicators = true,
showArrows = true,
}: LiquidGlassCarouselProps) {
const transition = useLiquidTransition();
const tapScale = useLiquidTapScale();
const [current, setCurrent] = useState(0);
const [direction, setDirection] = useState(0);
const goTo = useCallback(
(index: number) => {
setDirection(index > current ? 1 : -1);
setCurrent((index + items.length) % items.length);
},
[current, items.length]
);
const next = useCallback(() => goTo(current + 1), [current, goTo]);
const prev = useCallback(() => goTo(current - 1), [current, goTo]);
useEffect(() => {
if (!autoPlay) return;
const timer = setInterval(next, interval);
return () => clearInterval(timer);
}, [autoPlay, interval, next]);
const variants = {
enter: (dir: number) => ({ x: dir > 0 ? 300 : -300, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir > 0 ? -300 : 300, opacity: 0 }),
};
return (
<div className={cn("relative overflow-hidden rounded-2xl", className)}>
<div className="relative aspect-video">
<AnimatePresence initial={false} custom={direction} mode="popLayout">
<motion.div
key={items[current].id}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={transition}
className="absolute inset-0"
>
{items[current].content}
</motion.div>
</AnimatePresence>
</div>
{showArrows && items.length > 1 && (
<>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: tapScale }}
onClick={prev}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 flex h-9 w-9 items-center justify-center rounded-full glass-blur-sm glass-surface glass-border text-[var(--lg-text-secondary)] hover:text-[var(--lg-text)] transition-colors"
>
<ChevronLeft size={18} />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: tapScale }}
onClick={next}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 flex h-9 w-9 items-center justify-center rounded-full glass-blur-sm glass-surface glass-border text-[var(--lg-text-secondary)] hover:text-[var(--lg-text)] transition-colors"
>
<ChevronRight size={18} />
</motion.button>
</>
)}
{showIndicators && items.length > 1 && (
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1.5">
{items.map((_, i) => (
<motion.button
key={i}
onClick={() => goTo(i)}
className={cn(
"h-1.5 rounded-full transition-all duration-300",
i === current
? "w-6 bg-[var(--lg-text-muted)]"
: "w-1.5 bg-[var(--lg-text-muted)] hover:bg-[var(--lg-text-muted)]"
)}
/>
))}
</div>
)}
</div>
);
}
Open in interactive docs
LiquidGlassCheckbox
Category: Inputs, Toggles & Pickers
Glass checkbox with indeterminate support.
Props
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
LiquidGlassControls
Category: Theme & Glass Primitives
Interactive panel to adjust blur, transparency, and saturation.
Props
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
LiquidGlassModal
Category: Modals, Drawers & Sheets
Centered modal dialog with glass blur.
Props
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
LiquidGlassPressSplash
Category: Theme & Glass Primitives
Animated press splash used by buttons and chips.
Props
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
MobileSearchBar
Category: Inputs, Toggles & Pickers
iOS-style search bar with cancel button.
Props
Prop Type Required Description
placeholder
string
No
Placeholder text.
value
string
No
Current value of the control.
onChange
(value: string) => void
No
Callback when the value changes.
onCancel
() => void
No
-
showCancelButton
boolean
No
-
className
string
No
Additional Tailwind CSS classes.
autoFocus
boolean
No
-
Usage
<MobileSearchBar />
Source
import { cn } from "../../utils/cn";
import { motion, AnimatePresence } from "framer-motion";
import { Search, X } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { GlassTopHighlight } from "./GlassTopHighlight";
import { GlassSheen } from "./GlassSheen";
import { useLiquidTransition } from "./useLiquidMotion";
interface MobileSearchBarProps {
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
onCancel?: () => void;
showCancelButton?: boolean;
className?: string;
autoFocus?: boolean;
}
export function MobileSearchBar({
placeholder = "Search",
value: controlledValue,
onChange,
onCancel,
showCancelButton,
className,
autoFocus = false,
}: MobileSearchBarProps) {
const transition = useLiquidTransition();
const [value, setValue] = useState(controlledValue || "");
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setValue(controlledValue || "");
}, [controlledValue]);
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
const isActive = isFocused || value.length > 0;
const isControlled = controlledValue !== undefined;
const displayValue = isControlled ? controlledValue : value;
const handleClear = () => {
if (isControlled) {
onChange?.("");
} else {
setValue("");
}
inputRef.current?.focus();
};
return (
<motion.div
animate={{
scale: isFocused ? 1.01 : 1,
...(isFocused && {
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={transition}
className={cn(
"relative flex items-center gap-2 px-3 py-2 rounded-xl transition-all duration-200 overflow-hidden",
"glass-blur glass-surface-strong glass-border glass-highlight",
isActive && "ring-2 ring-white/20",
className
)}
>
{/* Top highlight */}
<GlassTopHighlight className="inset-x-3 top-0" opacity={0.3} />
{/* Reflection blob */}
<div className="pointer-events-none absolute -top-5 -right-5 h-14 w-14 rounded-full glass-reflection blur-2xl" />
{/* Sheen */}
<GlassSheen opacity={0.12} />
<Search size={16} className="relative z-10 text-[var(--lg-text-muted)] flex-shrink-0" />
<input
ref={inputRef}
value={displayValue}
onChange={(e) => {
if (isControlled) onChange?.(e.target.value);
else setValue(e.target.value);
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
className="relative z-10 flex-1 bg-transparent text-sm text-[var(--lg-text)] placeholder-[var(--lg-text-muted)] outline-none"
/>
<AnimatePresence>
{displayValue.length > 0 && (
<motion.button
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
onClick={handleClear}
className="relative z-10 flex-shrink-0 text-[var(--lg-text-muted)] hover:text-[var(--lg-text-secondary)]"
>
<X size={16} />
</motion.button>
)}
</AnimatePresence>
{(showCancelButton || isActive) && (
<button
className="relative z-10 flex-shrink-0 text-sm font-medium text-liquid-blue"
onClick={() => {
handleClear();
onCancel?.();
}}
>
Cancel
</button>
)}
</motion.div>
);
}
Open in interactive docs
MobileSegmentedControl
Category: Inputs, Toggles & Pickers
iOS segmented control.
Props
Prop Type Required Description
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
MobileSnackbar
Category: Feedback & Status
Bottom snackbar with progress and action.
Props
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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
Prop Type Required Description
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