# Liquid Tabs > A tab switcher with a physics-driven, stretching selection pill. Install: `npx @modus-ui/cli add liquid-tabs` A beautiful navigation primitive. The tab indicator mathematically squishes and stretches based on the physical velocity of the spring transition, rather than relying on standard hard-coded scales. If you jump multiple tabs, it stretches more! ## Installation ### CLI ```bash npx @modus-ui/cli add liquid-tabs ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/liquid-tabs.tsx`. 3. Paste the code. ## Usage ```tsx import { LiquidTabs } from "@/components/ui/liquid-tabs"; export default function Example() { const tabs = [ { label: "Home", value: "home" }, { label: "Profile", value: "profile" }, { label: "Settings", value: "settings" }, ]; return ; } ``` ## Props | Prop | Type | Default | Description | | :-------------- | :------------------------ | :------ | :----------------------------------------------------------------------- | | `items` | `LiquidTabItem[]` | - | The tabs to render. | | `defaultValue` | `string` | - | The initially active tab when uncontrolled. | | `value` | `string` | - | The active tab when controlled. | | `onValueChange` | `(value: string) => void` | - | Called with the new value when a tab is selected. | | `squish` | `number` | `0.22` | How much the indicator stretches while it moves (0 disables the effect). | ## Source `components/ui/liquid-tabs.tsx` ```tsx "use client"; import { motion, useReducedMotion, useSpring, useTransform, useVelocity } from "framer-motion"; import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, } from "react"; import { cn } from "@/lib/utils"; interface LiquidTabItem { label: string; value: string; } interface LiquidTabsProps { items: LiquidTabItem[]; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; squish?: number; className?: string; } export function LiquidTabs({ items, defaultValue, value, onValueChange, squish = 0.22, className = "", }: LiquidTabsProps) { const prefersReducedMotion = useReducedMotion(); const listRef = useRef(null); const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); const measured = useRef(false); const [uncontrolled, setUncontrolled] = useState(defaultValue ?? items[0]?.value ?? ""); const selected = value ?? uncontrolled; const activeIndex = Math.max( 0, items.findIndex((item) => item.value === selected), ); const spring = { stiffness: 420, damping: 34, mass: 0.9 }; const x = useSpring(0, spring); const width = useSpring(0, spring); const velocity = useVelocity(x); const stretch = useTransform(velocity, [-2600, 0, 2600], [1 + squish, 1, 1 + squish], { clamp: true, }); const scaleX = useSpring(stretch, { stiffness: 500, damping: 30 }); const scaleY = useTransform(scaleX, (current) => 1 / current); const sync = useCallback(() => { const tab = tabRefs.current[activeIndex]; const list = listRef.current; if (!tab || !list) return; const left = tab.offsetLeft; const tabWidth = tab.offsetWidth; if (!measured.current || prefersReducedMotion) { measured.current = true; x.jump(left); width.jump(tabWidth); return; } x.set(left); width.set(tabWidth); }, [activeIndex, prefersReducedMotion, x, width]); useLayoutEffect(sync, [sync]); useEffect(() => { const list = listRef.current; if (!list || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(sync); observer.observe(list); return () => observer.disconnect(); }, [sync]); const select = (next: string) => { if (value === undefined) setUncontrolled(next); onValueChange?.(next); }; const handleKeyDown = (event: KeyboardEvent) => { const moves: Record = { ArrowRight: 1, ArrowLeft: -1, Home: -activeIndex, End: items.length - 1 - activeIndex, }; const step = moves[event.key]; if (step === undefined) return; event.preventDefault(); const next = (activeIndex + step + items.length) % items.length; const target = items[next]; if (!target) return; select(target.value); tabRefs.current[next]?.focus(); }; return (
); } ``` --- # Flowing Menu > A full-screen menu where hovering an item triggers an infinite scrolling marquee. Install: `npx @modus-ui/cli add flowing-menu` A full-screen menu where hovering an item triggers an infinite scrolling marquee mask that reveals the image and text based on mouse entry position. ## Installation ### CLI ```bash npx @modus-ui/cli add flowing-menu ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/flowing-menu.tsx`. 3. Paste the code. ## Usage ```tsx import { FlowingMenu } from "@/components/ui/flowing-menu"; export default function Example() { const items = [ { link: "#", text: "Home", image: "..." }, { link: "#", text: "About", image: "..." }, ]; return (
); } ``` ## Props | Prop | Type | Default | Description | | :------ | :---------------- | :------ | :------------------------------------------------ | | `items` | `MenuItemProps[]` | `[]` | The menu entries to render. | | `speed` | `number` | `15` | Duration in seconds of one marquee loop on hover. | ## Source `components/ui/flowing-menu.tsx` ```tsx "use client"; // Full-height menu rows; hovering one slides in a looping marquee from the edge the cursor entered. import { useState } from "react"; import type React from "react"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface MenuItemProps { link: string; text: string; image: string; } export interface FlowingMenuProps { items?: MenuItemProps[]; speed?: number; className?: string; } type Edge = "top" | "bottom"; function edgeOf(e: React.MouseEvent): Edge { const rect = e.currentTarget.getBoundingClientRect(); return e.clientY - rect.top < rect.height / 2 ? "top" : "bottom"; } function Row({ item, speed }: { item: MenuItemProps; speed: number }) { const reduceMotion = useReducedMotion(); const [edge, setEdge] = useState(null); const [exitEdge, setExitEdge] = useState("top"); const offset = (side: Edge) => (side === "top" ? "-101%" : "101%"); const strip = Array.from({ length: 4 }, (_, i) => ( {item.text} )); return ( setEdge(edgeOf(e))} onMouseLeave={(e) => { setExitEdge(edgeOf(e)); setEdge(null); }} onFocus={() => setEdge("top")} onBlur={() => setEdge(null)} > {item.text} {edge && ( {strip} {strip} )} ); } export function FlowingMenu({ items = [], speed = 15, className }: FlowingMenuProps) { return ( ); } ``` --- # Morphing Navbar > A premium, scroll-aware header that dynamically adjusts its layout density and logo scale based on scroll position. Install: `npx @modus-ui/cli add morphing-navbar` A premium, scroll-aware navigation header that dynamically adjusts its layout, logo scale, and background density as the user scrolls. Part of the Premium UI series. ## Installation ### CLI ```bash npx @modus-ui/cli add morphing-navbar ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/morphing-navbar.tsx`. 3. Paste the code. ## Usage ```tsx import { MorphingNavbar } from "@/components/ui/morphing-navbar"; import { GraduationCap } from "lucide-react"; export default function Example() { return ( ); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :------------ | :------------------ | :-------------------------------------------------------------------- | | `title` | `string` | `"Morphing Navbar"` | The title text displayed next to the logo. | | `logo` | `ElementType` | `GraduationCap` | The icon component to display as the logo. | | `breadcrumbSteps` | `Step[]` | `[]` | Array of breadcrumb steps to display in expanded mode. | | `onHomeClick` | `() => void` | - | Called when the home button is clicked. | | `onProfileClick` | `() => void` | - | Called when the profile avatar is clicked. | | `onToggleSidebar` | `() => void` | - | Called when the sidebar toggle is clicked. | | `sidebarOpen` | `boolean` | `false` | Whether the sidebar is open (sets the mobile toggle icon). | | `scrollThreshold` | `number` | `36` | Scroll distance in pixels before morphing to collapsed state. | | `targetId` | `string` | - | Optional ID of a scrollable container to track instead of the window. | ## Source `components/ui/morphing-navbar.tsx` ```tsx "use client"; import React, { useEffect, useState } from "react"; import Image from "next/image"; import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; import { Menu, X, ChevronRight, GraduationCap, Moon, Sun } from "lucide-react"; import { cn } from "@/lib/utils"; // --- Simplified UI Helpers (Standalone) --- const NavButton = ({ children, onClick, className, variant = "primary", }: { children: React.ReactNode; onClick?: () => void; className?: string; variant?: "primary" | "ghost" | "outline"; }) => { const variants = { primary: "bg-primary text-primary-foreground hover:opacity-90 shadow-sm", ghost: "bg-transparent text-foreground hover:bg-accent", outline: "border border-border bg-card/60 text-foreground hover:bg-accent", }; return ( ); }; const UserAvatar = ({ src, alt }: { src?: string; alt: string }) => (
{src ? ( {alt} ) : ( {alt.charAt(0)} )}
); // --- Main Component --- /** * MorphingNavbar is a premium, scroll-aware header that adjusts its layout density * based on the scroll position of the page or a target container. */ export function MorphingNavbar({ title = "Morphing Navbar", logo: Logo = GraduationCap, breadcrumbSteps = [], onHomeClick = () => {}, onProfileClick = () => {}, onToggleSidebar = () => {}, sidebarOpen = false, scrollThreshold = 36, targetId, }: { /** The title text displayed next to the logo */ title?: string; /** The icon component to display as the logo */ logo?: React.ElementType; /** Array of breadcrumb steps to display in expanded mode */ breadcrumbSteps?: { label: string; href?: string }[]; /** Callback for home button click */ onHomeClick?: () => void; /** Callback for profile/avatar click */ onProfileClick?: () => void; /** Callback for sidebar toggle button */ onToggleSidebar?: () => void; /** Boolean indicating if the sidebar is currently open (for mobile toggle icon) */ sidebarOpen?: boolean; /** Scroll distance in pixels before morphing to collapsed state */ scrollThreshold?: number; /** Optional ID of a scrollable container to track instead of the window */ targetId?: string; }) { const [collapsed, setCollapsed] = useState(false); const [isDarkMode, setIsDarkMode] = useState(false); const prefersReducedMotion = useReducedMotion(); const instant = prefersReducedMotion ? { duration: 0 } : undefined; useEffect(() => { const scrollTarget = targetId ? document.getElementById(targetId) : window; if (!scrollTarget) return; const handleScroll = () => { const scrollY = targetId ? (scrollTarget as HTMLElement).scrollTop : window.scrollY; setCollapsed(scrollY > scrollThreshold); }; // Check initial scroll position handleScroll(); scrollTarget.addEventListener("scroll", handleScroll, { passive: true }); return () => scrollTarget.removeEventListener("scroll", handleScroll); }, [scrollThreshold, targetId]); return (
{/* Left Section: Logo & Mobile Toggle */}
{/* Right Section: Actions */}
Home
{/* Breadcrumbs (Expandable) */} {!collapsed && breadcrumbSteps.length > 0 && (
All Pages {breadcrumbSteps.map((step, i) => ( {step.label} ))}
)}
{/* Subtle bottom accent line */}
); } ``` --- # Floating Dock > A premium navigation dock with smooth Apple-style magnifying zoom animation on hover. Install: `npx @modus-ui/cli add floating-dock` A premium floating dock navigation bar. Hovering over the icons enlarges them and their immediate neighbors dynamically using spring-based motion. Perfect for modern, app-like landing pages. ## Installation ### CLI ```bash npx @modus-ui/cli add floating-dock ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/floating-dock.tsx`. 3. Paste the code. ## Usage ```tsx import { FloatingDock } from "@/components/ui/floating-dock"; import { Home, Compass, Sparkles, Mail } from "lucide-react"; export default function Example() { const items = [ { title: "Home", icon: , href: "/" }, { title: "Explore", icon: , href: "/explore" }, { title: "Features", icon: , onClick: () => console.log("clicked") }, { title: "Contact", icon: , href: "/contact" }, ]; return ; } ``` ## Props | Prop | Type | Default | Description | | :---------- | :------------------- | :--------- | :---------------------------------------------------------------------------------------- | | `items` | `FloatingDockItem[]` | - | An array of navigation item objects containing title, icon, and optional href or onClick. | | `direction` | `"top" \| "bottom"` | `"bottom"` | The alignment and expansion direction of the tooltips. | | `className` | `string` | - | Custom container styles. | ## Source `components/ui/floating-dock.tsx` ```tsx "use client"; import React, { useRef, useState } from "react"; import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, AnimatePresence, } from "framer-motion"; import { cn } from "@/lib/utils"; export interface FloatingDockItem { title: string; icon: React.ReactNode; href?: string; onClick?: () => void; } export interface FloatingDockProps { items: FloatingDockItem[]; className?: string; direction?: "top" | "bottom"; } export function FloatingDock({ items, className, direction = "bottom" }: FloatingDockProps) { const mouseX = useMotionValue(Infinity); const prefersReducedMotion = useReducedMotion(); return ( !prefersReducedMotion && mouseX.set(e.pageX)} onMouseLeave={() => mouseX.set(Infinity)} className={cn( "relative mx-auto flex h-16 items-end gap-4 overflow-hidden rounded-3xl border border-border bg-background/90 px-4 pb-3 shadow-xl backdrop-blur-md", "before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle,_#37322F_1px,_transparent_1px)] before:bg-[length:8px_8px] before:opacity-[0.03]", className, )} > {items.map((item, idx) => ( ))} ); } interface DockIconProps { mouseX: any; title: string; icon: React.ReactNode; href?: string; onClick?: () => void; direction: "top" | "bottom"; reducedMotion: boolean; } function DockIcon({ mouseX, title, icon, href, onClick, direction, reducedMotion }: DockIconProps) { const ref = useRef(null); const [hovered, setHovered] = useState(false); const distance = useTransform(mouseX, (val: number) => { const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 }; return val - bounds.x - bounds.width / 2; }); // Calculate icon scaling relative to distance from pointer const sizeTransform = useTransform(distance, [-120, 0, 120], [40, 60, 40]); const iconSizeTransform = useTransform(distance, [-120, 0, 120], [18, 30, 18]); const size = useSpring(sizeTransform, { mass: 0.1, stiffness: 200, damping: 15, }); const iconSize = useSpring(iconSizeTransform, { mass: 0.1, stiffness: 200, damping: 15, }); const Content = ( setHovered(true)} onMouseLeave={() => setHovered(false)} whileTap={reducedMotion ? undefined : { scale: 0.9 }} className={cn( "relative flex items-center justify-center rounded-full border border-border bg-card text-foreground shadow-sm transition-colors hover:bg-background", "group cursor-pointer", )} onClick={onClick} > {hovered && ( {title} )} {React.isValidElement(icon) ? React.cloneElement(icon as React.ReactElement, { className: "w-full h-full" }) : icon} ); if (href) { return ( {Content} ); } return Content; } ``` --- # Notch Footer > A premium, animated footer component with a smooth curved pocket. Install: `npx @modus-ui/cli add notch-footer` A premium, animated footer and call-to-action banner featuring a mathematically perfect curved notch pocket. Built with **Framer Motion** and **Tailwind CSS**. It is fully responsive and supports custom branding, dot grid indicators, and physics-based hover scaling out of the box. ## Installation ### CLI ```bash npx @modus-ui/cli add notch-footer ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/notch-footer.tsx`. 3. Paste the code. ## Usage ```tsx import { NotchFooter } from "@/components/notch-footer"; export default function Example() { const handleAction = () => { console.log("Action clicked!"); }; return ( ); } ``` ## Props | Prop | Type | Default | Description | | :----------------- | :----------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------- | | `title` | `string` | `"Stop guessing about your digital experience with Modus"` | The primary headline rendered inside the banner | | `buttonText` | `string` | `"Get started for free"` | Text inside the bottom outline action button | | `onButtonClick` | `() => void` | - | Callback function triggered when the CTA button is clicked | | `buttonHref` | `string` | - | Optional destination link for the button (turns it into an `a` tag) | | `logo` | `ReactNode` | - | Custom logo element floating gracefully inside the notch pocket | | `bgColorClass` | `string` | `"bg-primary"` | Tailwind class(es) setting the main banner's background | | `titleColorClass` | `string` | `"!text-primary-foreground"` | Tailwind class(es) for the headline colour. | | `buttonColorClass` | `string` | `"border-primary-foreground/20 hover:border-primary-foreground bg-primary-foreground/10 hover:bg-primary-foreground !text-primary-foreground hover:!text-primary"` | Tailwind class(es) for the button. | | `dotColorClass` | `string` | `"bg-primary-foreground/30"` | Tailwind class(es) for the decorative dots. | | `notchFillColor` | `string` | `"hsl(var(--background))"` | Hex color filling the notch overlay (should match your page background) | | `className` | `string` | - | Additional CSS styling classes for the container | ## Source `components/ui/notch-footer.tsx` ```tsx "use client"; import React, { useState } from "react"; import { motion, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; interface NotchFooterProps { /** The main title text rendered inside the banner */ title?: string; /** Text for the outline button at the bottom */ buttonText?: string; /** Action triggered when the outline button is clicked */ onButtonClick?: () => void; /** Optional href for the CTA button */ buttonHref?: string; /** Logo or branding element placed inside the notch pocket */ logo?: React.ReactNode; /** Custom background class for the banner container (e.g., gradients) */ bgColorClass?: string; /** Custom text color class for the title */ titleColorClass?: string; /** Custom color classes for the CTA button */ buttonColorClass?: string; /** Custom background color class for the dots */ dotColorClass?: string; /** Background color for the notch pocket overlay (must match parent page background) */ notchFillColor?: string; /** Custom class name for the wrapper */ className?: string; } export function NotchFooter({ title = "Stop guessing about your digital experience with Modus", buttonText = "Get started for free", onButtonClick, buttonHref, logo, bgColorClass = "bg-primary", titleColorClass = "!text-primary-foreground", // Protect from .prose overrides buttonColorClass = "border-primary-foreground/20 hover:border-primary-foreground bg-primary-foreground/10 hover:bg-primary-foreground !text-primary-foreground hover:!text-primary", // Protect from .prose overrides dotColorClass = "bg-primary-foreground/30", notchFillColor = "hsl(var(--background))", className, }: NotchFooterProps) { const [isHovered, setIsHovered] = useState(false); const prefersReducedMotion = useReducedMotion(); // Reduced motion: hover parallax is disabled; the footer stays static. const hovered = isHovered && !prefersReducedMotion; return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className={cn( "relative flex w-full flex-col items-center justify-center overflow-hidden rounded-3xl px-8 py-16 text-center shadow-xl md:px-16", bgColorClass, className, )} > {/* 0. Border Outline Layer (Spatially layered at z-5, constructs the bottom, left, and right border frames) */}
{/* Left Top Border Segment (Stops exactly at the start of the notch curves) */}
{/* Right Top Border Segment (Starts exactly at the end of the notch curves) */}
{/* 1. Fluid Liquid Notch Pocket (Layered at z-10 to mask the top border line perfectly) */}
{/* 2. Logo container floating inside the Notch (Spatially isolated static wrapper to avoid Framer Motion transform overrides) */}
{logo || (
Modus
)}
{/* 3. Left Dotted Grid Decoration */}
{Array.from({ length: 12 }).map((_, i) => (
))}
{/* 4. Right Dotted Grid Decoration */}
{Array.from({ length: 12 }).map((_, i) => (
))}
{/* 5. Center Title */} {title} {/* 6. Outline CTA Button */}
{buttonHref ? ( ) : ( )}
); } /* Internal CTA Button Component with physics scales */ function CTAButton({ text, isHovered, buttonColorClass, }: { text: string; isHovered: boolean; buttonColorClass: string; }) { return ( {text} ); } ``` --- # Process Stepper > A wizard-style stepper component for breaking down complex forms and processes into manageable chunks. Install: `npx @modus-ui/cli add process-stepper` A wizard-style stepper component for breaking down complex forms and processes into manageable chunks. Features smooth, dynamic height transitions and animated progress indicators. ## Installation ### CLI ```bash npx @modus-ui/cli add process-stepper ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/process-stepper.tsx`. 3. Paste the code. ## Usage ```tsx import { ProcessStepper, Step } from "@/components/ui/process-stepper"; export default function Example() { return (

Step 1

This is the first step.

Step 2

This is the second step.

Step 3

This is the final step.

); } ``` ## Props | Prop | Type | Default | Description | | :---------------------- | :----------------------- | :----------- | :---------------------------------------------------- | | `initialStep` | `number` | `1` | The step to start on. | | `onStepChange` | `(step: number) => void` | - | Callback fired when the step changes. | | `onFinalStepCompleted` | `() => void` | - | Callback fired when the user completes the last step. | | `backButtonText` | `string` | `"Back"` | Text for the back button. | | `nextButtonText` | `string` | `"Continue"` | Text for the next button. | | `disableStepIndicators` | `boolean` | `false` | If true, clicking step indicators won't navigate. | ## Source `components/ui/process-stepper.tsx` ```tsx "use client"; import React, { useState, useRef, useLayoutEffect, Children } from "react"; import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface ProcessStepperProps extends React.HTMLAttributes { children: React.ReactNode; initialStep?: number; onStepChange?: (step: number) => void; onFinalStepCompleted?: () => void; backButtonText?: string; nextButtonText?: string; disableStepIndicators?: boolean; } export function ProcessStepper({ children, initialStep = 1, onStepChange = () => {}, onFinalStepCompleted = () => {}, backButtonText = "Back", nextButtonText = "Continue", disableStepIndicators = false, className, ...rest }: ProcessStepperProps) { const [currentStep, setCurrentStep] = useState(initialStep); const [direction, setDirection] = useState(0); const stepsArray = Children.toArray(children); const totalSteps = stepsArray.length; const isCompleted = currentStep > totalSteps; const isLastStep = currentStep === totalSteps; const updateStep = (newStep: number) => { setCurrentStep(newStep); if (newStep > totalSteps) { onFinalStepCompleted(); } else { onStepChange(newStep); } }; const handleBack = () => { if (currentStep > 1) { setDirection(-1); updateStep(currentStep - 1); } }; const handleNext = () => { if (!isLastStep) { setDirection(1); updateStep(currentStep + 1); } }; const handleComplete = () => { setDirection(1); updateStep(totalSteps + 1); }; return (
{stepsArray.map((_, index) => { const stepNumber = index + 1; const isNotLastStep = index < totalSteps - 1; return ( { setDirection(clicked > currentStep ? 1 : -1); updateStep(clicked); }} /> {isNotLastStep && stepNumber} />} ); })}
{stepsArray[currentStep - 1]} {!isCompleted && (
{currentStep !== 1 && ( )}
)}
); } function StepContentWrapper({ isCompleted, currentStep, direction, children, className }: any) { const [parentHeight, setParentHeight] = useState(0); const prefersReducedMotion = useReducedMotion(); return ( {!isCompleted && ( setParentHeight(h)} > {children} )} ); } function SlideTransition({ children, direction, reduced, onHeightReady }: any) { const containerRef = useRef(null); useLayoutEffect(() => { if (containerRef.current) onHeightReady(containerRef.current.offsetHeight); }, [children, onHeightReady]); return ( ({ x: reduced ? "0%" : dir >= 0 ? "-100%" : "100%", opacity: 0 }), center: { x: "0%", opacity: 1 }, exit: (dir) => ({ x: reduced ? "0%" : dir >= 0 ? "50%" : "-50%", opacity: 0 }), }} initial="enter" animate="center" exit="exit" transition={{ duration: reduced ? 0.15 : 0.4 }} style={{ position: "absolute", left: 0, right: 0, top: 0 }} > {children} ); } export function Step({ children, className }: { children: React.ReactNode; className?: string }) { return
{children}
; } function StepIndicator({ step, currentStep, onClickStep, disableStepIndicators }: any) { const status = currentStep === step ? "active" : currentStep < step ? "inactive" : "complete"; const prefersReducedMotion = useReducedMotion(); const handleClick = () => { if (step !== currentStep && !disableStepIndicators) onClickStep(step); }; return ( {status === "complete" ? ( ) : status === "active" ? (
) : ( {step} )} ); } function StepConnector({ isComplete }: { isComplete: boolean }) { const prefersReducedMotion = useReducedMotion(); return (
); } function CheckIcon(props: React.SVGProps) { const prefersReducedMotion = useReducedMotion(); return ( ); } ``` --- # Magnet > A micro-interaction component that pulls an element towards the mouse cursor. Install: `npx @modus-ui/cli add magnet` A simple but highly effective physics micro-interaction. It wraps any element and pulls it towards the mouse cursor as it approaches, creating a magnetic gravity effect. ## Installation ### CLI ```bash npx @modus-ui/cli add magnet ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/magnet.tsx`. 3. Paste the code. ## Usage ```tsx import { Magnet } from "@/components/ui/magnet"; export default function Example() { return ( ); } ``` ## Props | Prop | Type | Default | Description | | :------------------- | :-------- | :----------------------------- | :------------------------------------------------------------------ | | `padding` | `number` | `100` | Distance in pixels around the element within which the pull starts. | | `disabled` | `boolean` | `false` | Turns the magnetic effect off. | | `magnetStrength` | `number` | `2` | Divisor for the pull; higher values move the element less. | | `activeTransition` | `string` | `"transform 0.3s ease-out"` | CSS transition used while the cursor is in range. | | `inactiveTransition` | `string` | `"transform 0.5s ease-in-out"` | CSS transition used when the element springs back. | | `wrapperClassName` | `string` | `""` | Classes for the outer wrapper. | | `innerClassName` | `string` | `""` | Classes for the moving inner element. | ## Source `components/ui/magnet.tsx` ```tsx "use client"; // Pulls its child toward the cursor once the cursor comes within `padding` pixels. import { useEffect, useRef, useState, type ReactNode } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface MagnetProps { children: ReactNode; padding?: number; disabled?: boolean; magnetStrength?: number; activeTransition?: string; inactiveTransition?: string; wrapperClassName?: string; innerClassName?: string; } export function Magnet({ children, padding = 100, disabled = false, magnetStrength = 2, activeTransition = "transform 0.3s ease-out", inactiveTransition = "transform 0.5s ease-in-out", wrapperClassName = "", innerClassName = "", }: MagnetProps) { const ref = useRef(null); const reduceMotion = useReducedMotion(); const [pull, setPull] = useState<{ x: number; y: number } | null>(null); const off = disabled || reduceMotion; useEffect(() => { if (off) { setPull(null); return; } const onMove = (event: PointerEvent) => { const el = ref.current; if (!el) return; const box = el.getBoundingClientRect(); const inRange = event.clientX > box.left - padding && event.clientX < box.right + padding && event.clientY > box.top - padding && event.clientY < box.bottom + padding; setPull( inRange ? { x: (event.clientX - (box.left + box.width / 2)) / magnetStrength, y: (event.clientY - (box.top + box.height / 2)) / magnetStrength, } : null, ); }; window.addEventListener("pointermove", onMove); return () => window.removeEventListener("pointermove", onMove); }, [off, padding, magnetStrength]); return (
{children}
); } ``` --- # Magic Button > A smooth animated button using Framer Motion. Install: `npx @modus-ui/cli add magic-button` A smooth animated button built with **Framer Motion**. It supports hover scales and tap interactions out of the box. ## Installation ### CLI ```bash npx @modus-ui/cli add magic-button ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/magic-button.tsx`. 3. Paste the code. ## Usage ```tsx import { MagicButton } from "@/components/magic-button"; export default function Example() { return Magic; } ``` ## Source `components/ui/magic-button.tsx` ```tsx "use client"; import React from "react"; import { motion, useReducedMotion } from "framer-motion"; export const MagicButton = () => { const prefersReducedMotion = useReducedMotion(); return ( Magic Button ); }; ``` --- # Animated Button > A smooth button with hover and tap scale animations using Framer Motion. Install: `npx @modus-ui/cli add animated-button` A smooth button with hover and tap scale animations using Framer Motion. Built to provide tactile feedback and a premium feel to interactive elements. ## Installation ### CLI ```bash npx @modus-ui/cli add animated-button ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/animated-button.tsx`. 3. Paste the code. ## Usage ```tsx import { AnimatedButton } from "@/components/ui/animated-button"; export default function Example() { return Click Me; } ``` ## Props | Prop | Type | Default | Description | | :---------- | :---------- | :------ | :------------------------------------- | | `children` | `ReactNode` | - | The content inside the button. | | `className` | `string` | - | Additional CSS classes for the button. | ## Source `components/ui/animated-button.tsx` ```tsx "use client"; import { motion, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; interface AnimatedButtonProps { children: React.ReactNode; className?: string; } export function AnimatedButton({ children, className }: AnimatedButtonProps) { const prefersReducedMotion = useReducedMotion(); return ( {children} ); } ``` --- # Pulse Button > A button with an organic, rhythmic pulsing animation to draw user attention. Install: `npx @modus-ui/cli add pulse-button` A button with an organic, rhythmic pulsing animation to draw user attention. Perfect for primary calls to action or "live" indicators. ## Installation ### CLI ```bash npx @modus-ui/cli add pulse-button ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/pulse-button.tsx`. 3. Paste the code. ## Usage ```tsx import { PulseButton } from "@/components/ui/pulse-button"; export default function Example() { return Get Started; } ``` ## Props | Prop | Type | Default | Description | | :---------- | :---------- | :------ | :------------------------------------- | | `children` | `ReactNode` | - | The content inside the button. | | `className` | `string` | - | Additional CSS classes for the button. | ## Source `components/ui/pulse-button.tsx` ```tsx "use client"; import { motion, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; interface PulseButtonProps { children: React.ReactNode; className?: string; } export function PulseButton({ children, className }: PulseButtonProps) { const prefersReducedMotion = useReducedMotion(); return ( {children} ); } ``` --- # Bounce Cards > A stack of playing cards that fan out and straighten out interactively when hovered. Install: `npx @modus-ui/cli add bounce-cards` A stack of playing cards that fan out and straighten out interactively when hovered. This component dynamically calculates transform positions and spring physics using purely Framer Motion, skipping heavy libraries like GSAP. ## Installation ### CLI ```bash npx @modus-ui/cli add bounce-cards ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/bounce-cards.tsx`. 3. Paste the code. ## Usage ```tsx import { BounceCards } from "@/components/ui/bounce-cards"; export default function Example() { const images = ["https://...", "https://..."]; return ( ); } ``` ## Props | Prop | Type | Default | Description | | :----------------- | :----------------- | :------ | :---------------------------------------------- | | `images` | `string[]` | `[]` | Image URLs for the cards. | | `containerWidth` | `number \| string` | `400` | Width of the container. | | `containerHeight` | `number \| string` | `400` | Height of the container. | | `animationDelay` | `number` | `0.5` | Delay in seconds before the cards bounce in. | | `animationStagger` | `number` | `0.06` | Delay in seconds between each card. | | `enableHover` | `boolean` | `false` | Spreads the cards apart when one is hovered. | | `transformStyles` | `string[]` | `[...]` | CSS transform for each card's resting position. | ## Source `components/ui/bounce-cards.tsx` ```tsx "use client"; // A fanned stack of photo cards that springs in and parts around the hovered card. import { useState } from "react"; import { motion, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; const DEFAULT_TRANSFORMS = [ "rotate(10deg) translate(-170px)", "rotate(5deg) translate(-85px)", "rotate(-3deg)", "rotate(-10deg) translate(85px)", "rotate(2deg) translate(170px)", ]; const PUSH = 140; function parsePose(transform: string) { const rotate = Number(transform.match(/rotate\((-?[\d.]+)deg\)/)?.[1] ?? 0); const x = Number(transform.match(/translate(?:X)?\((-?[\d.]+)px/)?.[1] ?? 0); return { rotate, x }; } export interface BounceCardsProps { images?: string[]; containerWidth?: number | string; containerHeight?: number | string; animationDelay?: number; animationStagger?: number; enableHover?: boolean; transformStyles?: string[]; className?: string; } export function BounceCards({ images = [], containerWidth = 400, containerHeight = 400, animationDelay = 0.5, animationStagger = 0.06, enableHover = false, transformStyles = DEFAULT_TRANSFORMS, className, }: BounceCardsProps) { const reduceMotion = useReducedMotion(); const [active, setActive] = useState(null); return (
setActive(null)} > {images.map((src, i) => { const pose = parsePose(transformStyles[i] ?? ""); const focused = active === i; const shift = active === null || focused ? 0 : i < active ? -PUSH : PUSH; return ( setActive(i) : undefined} > ); })}
); } ``` --- # Masonry Grid > A fully responsive, animated masonry layout powered by Framer Motion. Install: `npx @modus-ui/cli add masonry-grid` A fully responsive, animated masonry layout powered by Framer Motion. It automatically calculates column sizes based on screen width and provides stunning entrance animations with blur effects. ## Installation ### CLI ```bash npx @modus-ui/cli add masonry-grid ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/masonry-grid.tsx`. 3. Paste the code. ## Usage ```tsx import { MasonryGrid } from "@/components/ui/masonry-grid"; export default function Example() { const items = [ { id: 1, height: 300, content:
}, { id: 2, height: 200, content:
}, { id: 3, height: 400, content:
}, ]; return ; } ``` ## Props | Prop | Type | Default | Description | | :------------ | :--------------------------------------------------------------- | :--------- | :--------------------------------------------------------- | | `items` | `MasonryItem[]` | `[]` | Array of objects containing `id`, `height`, and `content`. | | `animateFrom` | `"top" \| "bottom" \| "left" \| "right" \| "center" \| "random"` | `"bottom"` | Direction from which items animate in. | | `stagger` | `number` | `0.05` | Delay interval between item animations. | ## Source `components/ui/masonry-grid.tsx` ```tsx "use client"; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; const useMedia = (queries: string[], values: number[], defaultValue: number) => { const get = () => { if (typeof window === "undefined") return defaultValue; return values[queries.findIndex((q) => window.matchMedia(q).matches)] ?? defaultValue; }; const [value, setValue] = useState(get); useEffect(() => { const mediaQueries = queries.map((q) => window.matchMedia(q)); const handler = () => setValue(get); mediaQueries.forEach((query) => query.addEventListener("change", handler)); return () => mediaQueries.forEach((query) => query.removeEventListener("change", handler)); }, [queries]); return value; }; const useMeasure = () => { const ref = useRef(null); const [size, setSize] = useState({ width: 0, height: 0 }); useLayoutEffect(() => { if (!ref.current) return; const ro = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect; setSize({ width, height }); }); ro.observe(ref.current); return () => ro.disconnect(); }, []); return [ref, size] as const; }; export interface MasonryItem { id: string | number; height: number; content: React.ReactNode; } export interface MasonryGridProps { items: MasonryItem[]; animateFrom?: "top" | "bottom" | "left" | "right" | "center" | "random"; stagger?: number; className?: string; } export function MasonryGrid({ items, animateFrom = "bottom", stagger = 0.05, className, }: MasonryGridProps) { const columns = useMedia( ["(min-width: 1500px)", "(min-width: 1000px)", "(min-width: 600px)", "(min-width: 400px)"], [5, 4, 3, 2], 1, ); const [containerRef, { width }] = useMeasure(); const prefersReducedMotion = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); const grid = useMemo(() => { if (!width) return []; const colHeights = new Array(columns).fill(0); const gap = 16; const totalGaps = (columns - 1) * gap; const columnWidth = (width - totalGaps) / columns; return items.map((child) => { const col = colHeights.indexOf(Math.min(...colHeights)); const x = col * (columnWidth + gap); const height = child.height; const y = colHeights[col]; colHeights[col] += height + gap; return { ...child, x, y, w: columnWidth, h: height }; }); }, [columns, items, width]); const getInitialPosition = (item: any) => { if (typeof window === "undefined") return { x: item.x, y: item.y }; const containerRect = containerRef.current?.getBoundingClientRect(); if (!containerRect) return { x: item.x, y: item.y }; let direction = animateFrom; if (animateFrom === "random") { const dirs = ["top", "bottom", "left", "right"]; direction = dirs[Math.floor(Math.random() * dirs.length)] as any; } switch (direction) { case "top": return { x: item.x, y: -200 }; case "bottom": return { x: item.x, y: window.innerHeight + 200 }; case "left": return { x: -200, y: item.y }; case "right": return { x: window.innerWidth + 200, y: item.y }; case "center": return { x: containerRect.width / 2 - item.w / 2, y: containerRect.height / 2 - item.h / 2, }; default: return { x: item.x, y: item.y + 100 }; } }; const containerHeight = Math.max(...grid.map((i) => i.y + i.h), 0); return (
0 ? containerHeight : "100vh" }} > {mounted && width > 0 && grid.map((item, index) => ( {item.content} ))}
); } ``` --- # Animated List > A scrollable, animated list component with keyboard navigation, hover states, and smooth entrance physics. Install: `npx @modus-ui/cli add animated-list` A scrollable, animated list component with keyboard navigation, hover states, and smooth entrance physics. Perfect for settings menus, notifications, or selecting items from a large list. ## Installation ### CLI ```bash npx @modus-ui/cli add animated-list ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/animated-list.tsx`. 3. Paste the code. ## Usage ```tsx import { AnimatedList } from "@/components/ui/animated-list"; export default function Example() { const items = ["Item 1", "Item 2", "Item 3"]; return ; } ``` ## Props | Prop | Type | Default | Description | | :---------------------- | :----------------------------------- | :------ | :------------------------------------------------------------------------------ | | `items` | `ReactNode[] \| string[]` | `[...]` | The items to display in the list. | | `onItemSelect` | `(item: any, index: number) => void` | - | Callback when an item is clicked or selected via Enter key. | | `itemClassName` | `string` | - | Classes for each item. | | `showGradients` | `boolean` | `true` | Whether to show fading gradients at the top and bottom of the scroll container. | | `enableArrowNavigation` | `boolean` | `true` | Whether to enable up/down arrow and tab key navigation. | | `displayScrollbar` | `boolean` | `true` | Whether to show the scrollbar. | | `initialSelectedIndex` | `number` | `-1` | The index of the item that is selected initially. | ## Source `components/ui/animated-list.tsx` ```tsx "use client"; // Scrollable list whose rows rise into view, with keyboard selection. import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { motion, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; interface AnimatedListProps { items?: ReactNode[] | string[]; onItemSelect?: (item: any, index: number) => void; className?: string; itemClassName?: string; showGradients?: boolean; enableArrowNavigation?: boolean; displayScrollbar?: boolean; initialSelectedIndex?: number; } const SAMPLE = Array.from({ length: 12 }, (_, i) => `Item ${i + 1}`); export function AnimatedList({ items = SAMPLE, onItemSelect, className, itemClassName, showGradients = true, enableArrowNavigation = true, displayScrollbar = true, initialSelectedIndex = -1, }: AnimatedListProps) { const reduce = useReducedMotion(); const scroller = useRef(null); const rows = useRef<(HTMLLIElement | null)[]>([]); const [active, setActive] = useState(initialSelectedIndex); const [edges, setEdges] = useState({ top: false, bottom: false }); const measure = () => { const el = scroller.current; if (!el) return; setEdges({ top: el.scrollTop > 4, bottom: el.scrollTop + el.clientHeight < el.scrollHeight - 4, }); }; useEffect(measure, [items]); useEffect(() => { rows.current[active]?.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth", }); }, [active, reduce]); const select = (index: number) => { setActive(index); onItemSelect?.(items[index], index); }; const onKeyDown = (e: KeyboardEvent) => { if (!enableArrowNavigation) return; const step = e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey) ? 1 : e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey) ? -1 : 0; if (step) { const next = active + step; // Let Tab leave the list at either end. if (next < 0 || next >= items.length) { if (e.key !== "Tab") e.preventDefault(); return; } e.preventDefault(); setActive(next); } else if (e.key === "Enter" && active >= 0) { e.preventDefault(); select(active); } }; return (
= 0 ? `al-${active}` : undefined} className={cn( "max-h-[400px] overflow-y-auto rounded-xl p-1 outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--ring))]", !displayScrollbar && "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden", )} >
    {items.map((item, i) => ( { rows.current[i] = node; }} initial={reduce ? false : { opacity: 0, y: 16, scale: 0.96 }} whileInView={{ opacity: 1, y: 0, scale: 1 }} viewport={{ root: scroller, amount: 0.4 }} transition={{ type: "spring", stiffness: 320, damping: 28 }} onMouseEnter={() => setActive(i)} onClick={() => select(i)} className={cn( "cursor-pointer rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-[hsl(var(--card-foreground))] transition-colors", i === active && "border-[hsl(var(--ring))] bg-[hsl(var(--accent))]", itemClassName, )} > {item} ))}
{showGradients && ( <>
)}
); } ``` --- # Kinetic Carousel > A modern, physics-based carousel that supports dragging, infinite looping, and 3D perspective transitions. Install: `npx @modus-ui/cli add kinetic-carousel` A modern, physics-based carousel that supports dragging, infinite looping, and 3D perspective transitions. Excellent for testimonials, feature spotlights, or image galleries. ## Installation ### CLI ```bash npx @modus-ui/cli add kinetic-carousel ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/kinetic-carousel.tsx`. 3. Paste the code. ## Usage ```tsx import { KineticCarousel } from "@/components/ui/kinetic-carousel"; export default function Example() { const items = [
Slide 1
,
Slide 2
,
Slide 3
, ]; return ; } ``` ## Props | Prop | Type | Default | Description | | :-------------- | :------------ | :------ | :--------------------------------------------------------------------------------- | | `items` | `ReactNode[]` | `[]` | Array of React elements to display as slides. | | `baseWidth` | `number` | `320` | The total width of the carousel container in pixels. | | `autoplay` | `boolean` | `false` | Whether the carousel should automatically cycle. | | `autoplayDelay` | `number` | `3000` | The delay in milliseconds between automatic slide transitions. | | `pauseOnHover` | `boolean` | `false` | Whether to pause autoplay when the user hovers over the carousel. | | `loop` | `boolean` | `false` | Whether the carousel should infinitely loop from the last slide back to the first. | ## Source `components/ui/kinetic-carousel.tsx` ```tsx "use client"; import React, { useState, useEffect, useRef, useMemo } from "react"; import { motion, useMotionValue, useReducedMotion, useTransform, PanInfo } from "framer-motion"; import { cn } from "@/lib/utils"; const DRAG_BUFFER = 0; const VELOCITY_THRESHOLD = 500; const GAP = 16; const SPRING_OPTIONS = { type: "spring" as any, stiffness: 300, damping: 30 }; interface CarouselItemProps { children: React.ReactNode; index: number; itemWidth: number; trackItemOffset: number; x: any; transition: any; } function CarouselItem({ children, index, itemWidth, trackItemOffset, x, transition, }: CarouselItemProps) { const range = [ -(index + 1) * trackItemOffset, -index * trackItemOffset, -(index - 1) * trackItemOffset, ]; const outputRange = [90, 0, -90]; const rotateY = useTransform(x, range, outputRange, { clamp: false }); return ( {children} ); } export interface KineticCarouselProps extends React.HTMLAttributes { items: React.ReactNode[]; baseWidth?: number; autoplay?: boolean; autoplayDelay?: number; pauseOnHover?: boolean; loop?: boolean; } export function KineticCarousel({ items, baseWidth = 320, autoplay = false, autoplayDelay = 3000, pauseOnHover = false, loop = false, className, ...props }: KineticCarouselProps) { const containerPadding = 16; // Never wider than the space it is given, so cards stay whole on narrow screens. const [width, setWidth] = useState(baseWidth); const itemWidth = width - containerPadding * 2; const trackItemOffset = itemWidth + GAP; const itemsForRender = useMemo(() => { if (!loop) return items; if (items.length === 0) return []; return [items[items.length - 1], ...items, items[0]]; }, [items, loop]); const [position, setPosition] = useState(loop ? 1 : 0); const x = useMotionValue(0); const [isHovered, setIsHovered] = useState(false); const [isJumping, setIsJumping] = useState(false); const [isAnimating, setIsAnimating] = useState(false); const containerRef = useRef(null); useEffect(() => { const parent = containerRef.current?.parentElement; if (!parent) return; const observer = new ResizeObserver(([entry]) => { setWidth(Math.min(baseWidth, entry.contentRect.width || baseWidth)); }); observer.observe(parent); return () => observer.disconnect(); }, [baseWidth]); const prefersReducedMotion = useReducedMotion(); useEffect(() => { if (pauseOnHover && containerRef.current) { const container = containerRef.current; const handleMouseEnter = () => setIsHovered(true); const handleMouseLeave = () => setIsHovered(false); container.addEventListener("mouseenter", handleMouseEnter); container.addEventListener("mouseleave", handleMouseLeave); return () => { container.removeEventListener("mouseenter", handleMouseEnter); container.removeEventListener("mouseleave", handleMouseLeave); }; } }, [pauseOnHover]); useEffect(() => { // Reduced motion: no auto-advance; drag and the dots still work. if (!autoplay || prefersReducedMotion || itemsForRender.length <= 1) return; if (pauseOnHover && isHovered) return; const timer = setInterval(() => { setPosition((prev) => Math.min(prev + 1, itemsForRender.length - 1)); }, autoplayDelay); return () => clearInterval(timer); }, [ autoplay, prefersReducedMotion, autoplayDelay, isHovered, pauseOnHover, itemsForRender.length, ]); useEffect(() => { const startingPosition = loop ? 1 : 0; setPosition(startingPosition); x.set(-startingPosition * trackItemOffset); }, [items.length, loop, trackItemOffset, x]); useEffect(() => { if (!loop && position > itemsForRender.length - 1) { setPosition(Math.max(0, itemsForRender.length - 1)); } }, [itemsForRender.length, loop, position]); const effectiveTransition = isJumping || prefersReducedMotion ? { duration: 0 } : SPRING_OPTIONS; const handleAnimationStart = () => setIsAnimating(true); const handleAnimationComplete = () => { if (!loop || itemsForRender.length <= 1) { setIsAnimating(false); return; } const lastCloneIndex = itemsForRender.length - 1; if (position === lastCloneIndex) { setIsJumping(true); setPosition(1); x.set(-1 * trackItemOffset); requestAnimationFrame(() => { setIsJumping(false); setIsAnimating(false); }); return; } if (position === 0) { setIsJumping(true); setPosition(items.length); x.set(-items.length * trackItemOffset); requestAnimationFrame(() => { setIsJumping(false); setIsAnimating(false); }); return; } setIsAnimating(false); }; const handleDragEnd = (_: any, info: PanInfo) => { const { offset, velocity } = info; const direction = offset.x < -DRAG_BUFFER || velocity.x < -VELOCITY_THRESHOLD ? 1 : offset.x > DRAG_BUFFER || velocity.x > VELOCITY_THRESHOLD ? -1 : 0; if (direction === 0) return; setPosition((prev) => { const next = prev + direction; const max = itemsForRender.length - 1; return Math.max(0, Math.min(next, max)); }); }; const dragProps = loop ? {} : { dragConstraints: { left: -trackItemOffset * Math.max(itemsForRender.length - 1, 0), right: 0, }, }; const activeIndex = items.length === 0 ? 0 : loop ? (position - 1 + items.length) % items.length : Math.min(position, items.length - 1); if (items.length === 0) return null; return (
{itemsForRender.map((item, index) => ( {item} ))}
{items.map((_, index) => ( setPosition(loop ? index + 1 : index)} transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.15 }} /> ))}
); } ``` --- # Expandable Showcase > A horizontal or vertical list of items where hovering or clicking expands one item while shrinking others. Install: `npx @modus-ui/cli add expandable-showcase` A horizontal or vertical list of items where hovering or clicking expands one item while shrinking others. Perfect for image galleries, product showcases, or interactive feature lists. ## Installation ### CLI ```bash npx @modus-ui/cli add expandable-showcase ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/expandable-showcase.tsx`. 3. Paste the code. ## Usage ```tsx import { ExpandableShowcase } from "@/components/ui/expandable-showcase"; export default function Example() { const items = [ { id: 1, image: "/img1.jpg", label: "Mountain" }, { id: 2, image: "/img2.jpg", label: "River" }, { id: 3, image: "/img3.jpg", label: "Forest" }, ]; return ; } ``` ## Props | Prop | Type | Default | Description | | :------------- | :--------------------------- | :------------- | :------------------------------------------------ | | `items` | `ShowcaseItem[]` | - | Array of objects containing id, image, and label. | | `defaultIndex` | `number` | `0` | The index of the initially expanded item. | | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Direction of the gallery. | | `height` | `number \| string` | `460` | Height of the container. | | `gap` | `number` | `12` | Spacing between items. | ## Source `components/ui/expandable-showcase.tsx` ```tsx "use client"; import React, { useState } from "react"; import { motion, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface ShowcaseItem { id: string | number; image: string; label: string; link?: string; alt?: string; } export interface ExpandableShowcaseProps extends React.HTMLAttributes { items: ShowcaseItem[]; defaultIndex?: number; orientation?: "horizontal" | "vertical"; height?: number | string; gap?: number; className?: string; } export function ExpandableShowcase({ items, defaultIndex = 0, orientation = "horizontal", height = 460, gap = 12, className, ...props }: ExpandableShowcaseProps) { const [active, setActive] = useState(defaultIndex); const prefersReducedMotion = useReducedMotion(); const instant = { duration: 0 }; const isVertical = orientation === "vertical"; return (
{items.map((item, i) => { const isActive = i === active; // Use motion.a if link is provided, otherwise motion.div const Tag = item.link ? motion.a : motion.div; return ( setActive(i)} onFocus={() => setActive(i)} onMouseEnter={() => setActive(i)} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setActive(i); if (item.link) { window.location.href = item.link; } } else if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); setActive((i + 1) % items.length); } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); setActive((i - 1 + items.length) % items.length); } }} tabIndex={0} role="listitem" aria-expanded={isActive} className="group relative cursor-pointer overflow-hidden rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2" animate={{ flexGrow: isActive ? 4 : 1, flexShrink: isActive ? 0 : 1, flexBasis: isActive ? (isVertical ? "40%" : "40%") : "15%", }} transition={ prefersReducedMotion ? instant : { type: "spring", stiffness: 200, damping: 25 } } style={{ minHeight: isVertical ? 80 : "auto", }} > {/* Image Layer */} {item.alt {/* Gradient Overlay for Text Readability */} {/* Label and Indicator */}
{item.label}
); })}
); } ``` --- # Focus Card > A card with a subtle radial gradient spotlight that follows the user's cursor. Install: `npx @modus-ui/cli add focus-card` A card with a subtle radial gradient spotlight that follows the user's cursor, bringing attention to the elements you're hovering over. Great for feature grids and pricing cards. ## Installation ### CLI ```bash npx @modus-ui/cli add focus-card ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/focus-card.tsx`. 3. Paste the code. ## Usage ```tsx import { FocusCard } from "@/components/ui/focus-card"; export default function Example() { return (

Hover me

The spotlight follows your mouse.

); } ``` ## Props | Prop | Type | Default | Description | | :--------------- | :---------- | :---------------------------- | :------------------------------------------ | | `children` | `ReactNode` | - | The content inside the card. | | `className` | `string` | - | Additional CSS classes for the card. | | `spotlightColor` | `string` | `"rgba(255, 255, 255, 0.15)"` | The color of the spotlight radial gradient. | ## Source `components/ui/focus-card.tsx` ```tsx "use client"; import React, { useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface FocusCardProps extends React.HTMLAttributes { children: React.ReactNode; className?: string; spotlightColor?: string; } export function FocusCard({ children, className, spotlightColor = "rgba(255, 255, 255, 0.15)", ...props }: FocusCardProps) { const divRef = useRef(null); const [isFocused, setIsFocused] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const [opacity, setOpacity] = useState(0); const prefersReducedMotion = useReducedMotion(); const handleMouseMove = (e: React.MouseEvent) => { if (!divRef.current || isFocused || prefersReducedMotion) return; const rect = divRef.current.getBoundingClientRect(); setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top }); }; const handleFocus = () => { setIsFocused(true); setOpacity(0.6); }; const handleBlur = () => { setIsFocused(false); setOpacity(0); }; const handleMouseEnter = () => { setOpacity(0.6); }; const handleMouseLeave = () => { setOpacity(0); }; return (
); } ``` --- # Rotating Card > A 3D-style rotating card that responds to user hover with smooth perspective shifts. Install: `npx @modus-ui/cli add rotating-card` A 3D-style rotating card that responds to user hover with smooth perspective shifts. Adds depth and interactivity to feature displays or profile cards. ## Installation ### CLI ```bash npx @modus-ui/cli add rotating-card ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/rotating-card.tsx`. 3. Paste the code. ## Usage ```tsx import { RotatingCard } from "@/components/ui/rotating-card"; export default function Example() { return (

Feature Card

); } ``` ## Props | Prop | Type | Default | Description | | :---------- | :---------- | :------ | :----------------------------------- | | `children` | `ReactNode` | - | The content inside the card. | | `className` | `string` | - | Additional CSS classes for the card. | ## Source `components/ui/rotating-card.tsx` ```tsx "use client"; import { motion, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; interface RotatingCardProps { children: React.ReactNode; className?: string; } export function RotatingCard({ children, className }: RotatingCardProps) { const prefersReducedMotion = useReducedMotion(); return ( {children} ); } ``` --- # Tilt Card > A 3D parallax tilt card featuring smooth physics-based rotations on hover, collapsible tags and descriptions, and custom CTA buttons. Install: `npx @modus-ui/cli add tilt-card` A highly premium, interactive 3D parallax tilt card designed for presenting project portfolios, case studies, or feature cards. It responds dynamically to mouse/pointer hover with spring-loaded physics and wraps tags and long descriptions with collapsible states. ## Installation ### CLI ```bash npx @modus-ui/cli add tilt-card ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/tilt-card.tsx`. 3. Paste the code. ## Usage ```tsx import { TiltCard } from "@/components/ui/tilt-card"; export default function Example() { return ( ); } ``` ## Props | Prop | Type | Default | Description | | :----------------- | :------------------- | :------ | :------------------------------------------------------------------------------ | | `title` | `string` | - | The title of the card. | | `description` | `string` | - | Descriptive text for the project or feature. | | `image` | `string` | - | Source URL of the cover image. | | `tag` | `string` | - | A category badge overlaid on the top-right of the image (e.g. "Web", "Design"). | | `techStack` | `string \| string[]` | - | A comma-separated string or array of tags for technical skills/tools. | | `liveUrl` | `string` | - | URL for the interactive preview button. | | `githubUrl` | `string` | - | URL for the github source repository button. | | `caseStudyUrl` | `string` | - | URL to show a separate Case Study button. | | `onCaseStudyClick` | `() => void` | - | Optional custom handler when clicking Case Study. | | `maxTilt` | `number` | `15` | Maximum rotation angle in degrees. | | `perspective` | `number` | `1000` | Depth perspective value for 3D distortion. | | `children` | `ReactNode` | - | Direct children override if you wish to build custom inner layout. | | `className` | `string` | - | Custom container styles. | ## Source `components/ui/tilt-card.tsx` ```tsx "use client"; import React, { useRef, useState, useEffect } from "react"; import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion"; import { cn } from "@/lib/utils"; export interface TiltCardProps { title?: string; description?: string; image?: string; tag?: string; techStack?: string | string[]; liveUrl?: string; githubUrl?: string; caseStudyUrl?: string; onCaseStudyClick?: () => void; className?: string; maxTilt?: number; perspective?: number; children?: React.ReactNode; } export function TiltCard({ title, description, image, tag, techStack, liveUrl, githubUrl, caseStudyUrl, onCaseStudyClick, className, maxTilt = 15, perspective = 1000, children, }: TiltCardProps) { const cardRef = useRef(null); const [tiltEnabled, setTiltEnabled] = useState(true); const [isExpanded, setIsExpanded] = useState(false); const [isTechExpanded, setIsTechExpanded] = useState(false); const prefersReducedMotion = useReducedMotion(); // Reduced motion: the card stays flat and does not follow the pointer. const shouldTilt = tiltEnabled && !prefersReducedMotion; // Disable tilt on mobile/tablets for better UX useEffect(() => { const checkScreenSize = () => { setTiltEnabled(window.innerWidth >= 768); }; checkScreenSize(); window.addEventListener("resize", checkScreenSize); return () => window.removeEventListener("resize", checkScreenSize); }, []); const x = useMotionValue(0); const y = useMotionValue(0); const rotateXSpring = useSpring(y, { stiffness: 150, damping: 20 }); const rotateYSpring = useSpring(x, { stiffness: 150, damping: 20 }); const rotateX = useTransform(rotateXSpring, [-0.5, 0.5], [maxTilt, -maxTilt]); const rotateY = useTransform(rotateYSpring, [-0.5, 0.5], [-maxTilt, maxTilt]); const handleMouseMove = (event: React.MouseEvent) => { if (!shouldTilt || !cardRef.current) return; const rect = cardRef.current.getBoundingClientRect(); const width = rect.width; const height = rect.height; const relativeX = (event.clientX - rect.left) / width - 0.5; const relativeY = (event.clientY - rect.top) / height - 0.5; x.set(relativeX); y.set(relativeY); }; const handleMouseLeave = () => { x.set(0); y.set(0); }; // Process tech stack tags const tags: string[] = React.useMemo(() => { if (!techStack) return []; if (Array.isArray(techStack)) return techStack; return techStack .split(",") .map((t) => t.trim()) .filter(Boolean); }, [techStack]); const maxVisibleTags = 4; const hasMoreTags = tags.length > maxVisibleTags; const visibleTags = isTechExpanded ? tags : tags.slice(0, maxVisibleTags); const remainingTags = tags.length - maxVisibleTags; return ( {children ? ( children ) : (
{/* Project Image Section */} {image && (
{title {tag && ( {tag} )}
)} {/* Heading */}
{title && (

{title}

)} {/* Tech Stack Badges */} {tags.length > 0 && (
{visibleTags.map((tech, idx) => ( {tech} ))} {hasMoreTags && !isTechExpanded && ( )} {isTechExpanded && ( )}
)}
{/* Description */} {description && (

{description}

)} {/* CTAs */}
{liveUrl && ( )} {githubUrl && ( )} {(caseStudyUrl || onCaseStudyClick) && ( )}
)}
); } ``` --- # Cursor Grid > An interactive canvas grid that reacts to cursor proximity and clicks. Install: `npx @modus-ui/cli add cursor-grid` A highly optimized `` implementation of a grid that lights up based on cursor proximity and triggers ripples on click. Since it uses `requestAnimationFrame` and canvas 2D, it has a negligible impact on DOM performance, easily running at 60fps on huge containers. ## Installation ### CLI ```bash npx @modus-ui/cli add cursor-grid ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/cursor-grid.tsx`. 3. Paste the code. ## Usage ```tsx import { CursorGrid } from "@/components/ui/cursor-grid"; export default function Example() { return (
); } ``` ## Props | Prop | Type | Default | Description | | :------------- | :---------------------------------------- | :---------------------- | :----------------------------------------------------- | | `color` | `string` | `"hsl(var(--primary))"` | Colour of highlighted cells. | | `cellSize` | `number` | `32` | Size of each grid cell in pixels. | | `radius` | `number` | `120` | Radius in pixels of the cursor's influence. | | `falloff` | `"linear" \| "exponential" \| "gaussian"` | `"gaussian"` | How the highlight fades with distance from the cursor. | | `holdTime` | `number` | `0` | Milliseconds a cell stays lit before fading. | | `fadeDuration` | `number` | `600` | Milliseconds a cell takes to fade out. | | `lineWidth` | `number` | `1` | Width of the grid lines in pixels. | | `maxOpacity` | `number` | `1` | Maximum opacity of a highlighted cell. | | `fillOpacity` | `number` | `0.1` | Opacity of the fill of highlighted cells. | | `gridOpacity` | `number` | `0.05` | Opacity of the resting grid lines. | | `cellRadius` | `number` | `4` | Corner radius of each cell in pixels. | | `clickPulse` | `boolean` | `true` | Sends a ripple across the grid on click. | | `pulseSpeed` | `number` | `800` | Duration in milliseconds of the click ripple. | ## Source `components/ui/cursor-grid.tsx` ```tsx "use client"; // Canvas grid whose cells glow near the cursor and ripple outward on click. import * as React from "react"; import { useAnimationFrame, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; type Falloff = "linear" | "exponential" | "gaussian"; export interface CursorGridProps extends React.HTMLAttributes { color?: string; cellSize?: number; radius?: number; falloff?: Falloff; holdTime?: number; fadeDuration?: number; lineWidth?: number; maxOpacity?: number; fillOpacity?: number; gridOpacity?: number; cellRadius?: number; clickPulse?: boolean; pulseSpeed?: number; } const strength = (d: number, radius: number, falloff: Falloff) => { const t = d / radius; if (t >= 1) return 0; if (falloff === "linear") return 1 - t; if (falloff === "exponential") return Math.exp(-4 * t); return Math.exp(-(t * t) * 4); }; interface Field { cols: number; rows: number; heat: Float32Array; litAt: Float64Array; } export function CursorGrid({ color = "hsl(var(--primary))", cellSize = 32, radius = 120, falloff = "gaussian", holdTime = 0, fadeDuration = 600, lineWidth = 1, maxOpacity = 1, fillOpacity = 0.1, gridOpacity = 0.05, cellRadius = 4, clickPulse = true, pulseSpeed = 800, className, ...props }: CursorGridProps) { const reduceMotion = useReducedMotion(); const canvasRef = React.useRef(null); const pointer = React.useRef<{ x: number; y: number } | null>(null); const pulses = React.useRef<{ x: number; y: number; start: number }[]>([]); const field = React.useRef({ cols: 0, rows: 0, heat: new Float32Array(), litAt: new Float64Array(), }); const rgb = React.useRef("0, 0, 0"); React.useEffect(() => { const canvas = canvasRef.current!; const probe = document.createElement("span"); probe.style.color = color; canvas.parentElement!.appendChild(probe); rgb.current = getComputedStyle(probe) .color.replace(/rgba?\(|\)/g, "") .split(",") .slice(0, 3) .join(","); probe.remove(); }, [color]); React.useEffect(() => { const canvas = canvasRef.current!; const resize = () => { const { width, height } = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; canvas.getContext("2d")!.setTransform(dpr, 0, 0, dpr, 0, 0); const cols = Math.ceil(width / cellSize); const rows = Math.ceil(height / cellSize); field.current = { cols, rows, heat: new Float32Array(cols * rows), litAt: new Float64Array(cols * rows), }; }; resize(); const observer = new ResizeObserver(resize); observer.observe(canvas); return () => observer.disconnect(); }, [cellSize]); useAnimationFrame((_, delta) => { const now = performance.now(); const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) return; const { cols, rows, heat, litAt } = field.current; const w = canvas.width / (window.devicePixelRatio || 1); const h = canvas.height / (window.devicePixelRatio || 1); const ringWidth = cellSize * 1.5; const reach = Math.hypot(w, h); pulses.current = pulses.current.filter((p) => now - p.start < pulseSpeed); ctx.clearRect(0, 0, w, h); ctx.lineWidth = lineWidth; const inset = lineWidth / 2; const size = cellSize - lineWidth; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const i = row * cols + col; const cx = col * cellSize + cellSize / 2; const cy = row * cellSize + cellSize / 2; let target = 0; if (pointer.current) { target = strength( Math.hypot(cx - pointer.current.x, cy - pointer.current.y), radius, falloff, ); } for (const p of pulses.current) { const ring = ((now - p.start) / pulseSpeed) * reach; const off = Math.abs(Math.hypot(cx - p.x, cy - p.y) - ring); if (off < ringWidth) target = Math.max(target, (1 - off / ringWidth) * (1 - ring / reach)); } if (target >= heat[i]) { heat[i] = target; litAt[i] = now; } else if (reduceMotion || fadeDuration <= 0) { heat[i] = target; } else if (now - litAt[i] > holdTime) { heat[i] = Math.max(target, heat[i] - delta / fadeDuration); } const level = heat[i] * maxOpacity; ctx.beginPath(); ctx.roundRect(col * cellSize + inset, row * cellSize + inset, size, size, cellRadius); ctx.strokeStyle = `rgba(${rgb.current}, ${Math.max(gridOpacity, level)})`; ctx.stroke(); if (level > 0.01) { ctx.fillStyle = `rgba(${rgb.current}, ${level * fillOpacity})`; ctx.fill(); } } } }); const local = (e: React.PointerEvent) => { const rect = e.currentTarget.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }; return (
(pointer.current = local(e))} onPointerLeave={() => (pointer.current = null)} onPointerDown={(e) => { if (clickPulse && !reduceMotion) pulses.current.push({ ...local(e), start: performance.now() }); }} {...props} >
); } ``` --- # Waves > A reactive background of flowing lines driven by Perlin noise and cursor physics. Install: `npx @modus-ui/cli add waves` A physics-based line wave background that utilizes Perlin noise and cursor proximity to disturb the wave field dynamically. ## Installation ### CLI ```bash npx @modus-ui/cli add waves ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/waves.tsx`. 3. Paste the code. ## Usage ```tsx import { Waves } from "@/components/ui/waves"; export default function Example() { return (

Content over waves

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :-------------------- | :--------------------- | :--------------------------------------------------- | | `lineColor` | `string` | `"rgba(0, 0, 0, 0.2)"` | Colour of the wave lines. | | `backgroundColor` | `string` | `"transparent"` | Background colour behind the lines. | | `waveSpeedX` | `number` | `0.0125` | Horizontal speed of the noise field. | | `waveSpeedY` | `number` | `0.005` | Vertical speed of the noise field. | | `waveAmpX` | `number` | `32` | Horizontal amplitude of the waves. | | `waveAmpY` | `number` | `16` | Vertical amplitude of the waves. | | `xGap` | `number` | `10` | Horizontal gap between lines in pixels. | | `yGap` | `number` | `32` | Vertical gap between points in pixels. | | `friction` | `number` | `0.925` | How quickly cursor-induced motion settles. | | `tension` | `number` | `0.005` | How strongly points spring back to rest. | | `maxCursorMove` | `number` | `100` | Maximum displacement caused by the cursor in pixels. | | `style` | `React.CSSProperties` | `{}` | Inline styles for the container. | ## Source `components/ui/waves.tsx` ```tsx "use client"; // Canvas line field bent by gradient noise and pushed around by the cursor. import * as React from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface WavesProps { lineColor?: string; backgroundColor?: string; waveSpeedX?: number; waveSpeedY?: number; waveAmpX?: number; waveAmpY?: number; xGap?: number; yGap?: number; friction?: number; tension?: number; maxCursorMove?: number; style?: React.CSSProperties; className?: string; } // Small seeded 2D gradient noise in roughly [-1, 1]. const PERM = (() => { const p = Array.from({ length: 256 }, (_, i) => i); let seed = 1337; for (let i = 255; i > 0; i--) { seed = (seed * 16807) % 2147483647; const j = seed % (i + 1); [p[i], p[j]] = [p[j], p[i]]; } return [...p, ...p]; })(); function noise(x: number, y: number) { const xi = Math.floor(x); const yi = Math.floor(y); const fx = x - xi; const fy = y - yi; const corner = (cx: number, cy: number) => { const angle = (PERM[(PERM[(xi + cx) & 255] + yi + cy) & 511] / 256) * Math.PI * 2; return Math.cos(angle) * (fx - cx) + Math.sin(angle) * (fy - cy); }; const fade = (t: number) => t * t * (3 - 2 * t); const u = fade(fx); const v = fade(fy); const top = corner(0, 0) + (corner(1, 0) - corner(0, 0)) * u; const bottom = corner(0, 1) + (corner(1, 1) - corner(0, 1)) * u; return (top + (bottom - top) * v) * 1.4; } interface Point { x: number; y: number; dx: number; dy: number; vx: number; vy: number; } export function Waves({ lineColor = "rgba(0, 0, 0, 0.2)", backgroundColor = "transparent", waveSpeedX = 0.0125, waveSpeedY = 0.005, waveAmpX = 32, waveAmpY = 16, xGap = 10, yGap = 32, friction = 0.925, tension = 0.005, maxCursorMove = 100, style = {}, className, }: WavesProps) { const canvasRef = React.useRef(null); const reduceMotion = useReducedMotion(); React.useEffect(() => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) return; let columns: Point[][] = []; let width = 0; let height = 0; const cursor = { x: -1e4, y: -1e4, vx: 0, vy: 0 }; const build = () => { const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; width = rect.width; height = rect.height; canvas.width = width * dpr; canvas.height = height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); columns = []; for (let x = -xGap; x <= width + xGap; x += xGap) { const column: Point[] = []; for (let y = -yGap; y <= height + yGap; y += yGap) { column.push({ x, y, dx: 0, dy: 0, vx: 0, vy: 0 }); } columns.push(column); } }; const draw = (time: number) => { ctx.clearRect(0, 0, width, height); ctx.strokeStyle = lineColor; ctx.lineWidth = 1; ctx.beginPath(); for (const column of columns) { column.forEach((p, i) => { const n = noise((p.x + time * waveSpeedX) * 0.002, (p.y + time * waveSpeedY) * 0.0015); if (!reduceMotion) { const dist = Math.hypot(p.x - cursor.x, p.y - cursor.y); const reach = Math.max(175, Math.hypot(cursor.vx, cursor.vy) * 10); if (dist < reach) { const push = 1 - dist / reach; p.vx += cursor.vx * push * 0.0065; p.vy += cursor.vy * push * 0.0065; } p.vx = (p.vx - p.dx * tension) * friction; p.vy = (p.vy - p.dy * tension) * friction; p.dx = Math.max(-maxCursorMove, Math.min(maxCursorMove, p.dx + p.vx)); p.dy = Math.max(-maxCursorMove, Math.min(maxCursorMove, p.dy + p.vy)); } const x = p.x + Math.cos(n * Math.PI * 2) * waveAmpX + p.dx; const y = p.y + Math.sin(n * Math.PI) * waveAmpY + p.dy; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); } ctx.stroke(); }; let frame = 0; const tick = (time: number) => { cursor.vx *= 0.9; cursor.vy *= 0.9; draw(time / 16); frame = requestAnimationFrame(tick); }; const onMove = (event: PointerEvent) => { const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; if (cursor.x > -1e3) { cursor.vx = x - cursor.x; cursor.vy = y - cursor.y; } cursor.x = x; cursor.y = y; }; const observer = new ResizeObserver(() => { build(); if (reduceMotion) draw(0); }); observer.observe(canvas); build(); if (reduceMotion) draw(0); else { frame = requestAnimationFrame(tick); window.addEventListener("pointermove", onMove); } return () => { cancelAnimationFrame(frame); observer.disconnect(); window.removeEventListener("pointermove", onMove); }; }, [ lineColor, waveSpeedX, waveSpeedY, waveAmpX, waveAmpY, xGap, yGap, friction, tension, maxCursorMove, reduceMotion, ]); return (
); } ``` --- # Letter Glitch > A canvas-rendered procedural matrix of glitching letters and symbols. Install: `npx @modus-ui/cli add letter-glitch` A highly-optimized canvas component that renders a dense grid of randomly fluctuating letters, smoothly transitioning between specified colors. Includes built-in center and edge vignette effects. ## Installation ### CLI ```bash npx @modus-ui/cli add letter-glitch ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/letter-glitch.tsx`. 3. Paste the code. ## Usage ```tsx import { LetterGlitch } from "@/components/ui/letter-glitch"; export default function Example() { return (
); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :--------- | :---------------------------------------------------------------------- | :---------------------------------------------------- | | `glitchColors` | `string[]` | `["hsl(var(--primary))", "hsl(var(--primary))", "hsl(var(--primary))"]` | Colours the letters cycle through. | | `glitchSpeed` | `number` | `50` | Milliseconds between glitch updates. | | `centerVignette` | `boolean` | `true` | Darkens the centre with a radial vignette. | | `outerVignette` | `boolean` | `true` | Darkens the edges with a radial vignette. | | `smooth` | `boolean` | `true` | Fades between colours instead of switching instantly. | | `lightMode` | `boolean` | `false` | Uses light-theme vignettes. | | `backgroundColor` | `string` | - | Canvas background colour. | | `characters` | `string` | `"ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789"` | Characters the grid is drawn from. | ## Source `components/ui/letter-glitch.tsx` ```tsx "use client"; // Canvas grid of characters that keep scrambling and shifting colour. import * as React from "react"; import { useAnimationFrame, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface LetterGlitchProps extends React.HTMLAttributes { glitchColors?: string[]; glitchSpeed?: number; centerVignette?: boolean; outerVignette?: boolean; smooth?: boolean; lightMode?: boolean; backgroundColor?: string; characters?: string; } const CELL_W = 10; const CELL_H = 20; const SHARE = 0.05; type RGB = [number, number, number]; interface Cell { char: string; color: RGB; target: RGB; } const pick = (list: T[]) => list[Math.floor(Math.random() * list.length)]; export function LetterGlitch({ glitchColors = ["hsl(var(--primary))", "hsl(var(--primary))", "hsl(var(--primary))"], glitchSpeed = 50, centerVignette = true, outerVignette = true, smooth = true, lightMode = false, backgroundColor, characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789", className, ...props }: LetterGlitchProps) { const reduceMotion = useReducedMotion(); const canvasRef = React.useRef(null); const cells = React.useRef([]); const cols = React.useRef(0); const palette = React.useRef([[255, 255, 255]]); const lastGlitch = React.useRef(0); const glyphs = React.useMemo(() => Array.from(characters), [characters]); const shade = lightMode ? "255,255,255" : "0,0,0"; const colorKey = glitchColors.join("|"); const draw = React.useCallback(() => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font = `${CELL_H * 0.8}px ui-monospace, monospace`; ctx.textBaseline = "top"; cells.current.forEach((cell, i) => { ctx.fillStyle = `rgb(${cell.color.map(Math.round).join(",")})`; ctx.fillText(cell.char, (i % cols.current) * CELL_W, Math.floor(i / cols.current) * CELL_H); }); }, []); React.useEffect(() => { const canvas = canvasRef.current!; const probe = document.createElement("span"); canvas.parentElement!.appendChild(probe); palette.current = glitchColors.map((c) => { probe.style.color = c; return getComputedStyle(probe) .color.match(/[\d.]+/g)! .slice(0, 3) .map(Number) as RGB; }); probe.remove(); const resize = () => { const { width, height } = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; canvas.getContext("2d")!.setTransform(dpr, 0, 0, dpr, 0, 0); cols.current = Math.ceil(width / CELL_W); cells.current = Array.from({ length: cols.current * Math.ceil(height / CELL_H) }, () => { const color = pick(palette.current); return { char: pick(glyphs), color: [...color], target: color }; }); draw(); }; resize(); const observer = new ResizeObserver(resize); observer.observe(canvas); return () => observer.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [colorKey, glyphs, draw]); useAnimationFrame((time) => { if (reduceMotion) return; if (time - lastGlitch.current >= glitchSpeed) { lastGlitch.current = time; const count = Math.max(1, Math.floor(cells.current.length * SHARE)); for (let n = 0; n < count; n++) { const cell = pick(cells.current); if (!cell) break; cell.char = pick(glyphs); cell.target = pick(palette.current); if (!smooth) cell.color = [...cell.target]; } } if (smooth) { for (const cell of cells.current) { for (let k = 0; k < 3; k++) cell.color[k] += (cell.target[k] - cell.color[k]) * 0.08; } } draw(); }); return (
{outerVignette && (
)} {centerVignette && (
)}
); } ``` --- # Aurora Background > A stunning, smooth animated aurora borealis background effect using Framer Motion. Install: `npx @modus-ui/cli add aurora-background` A stunning, smooth animated aurora borealis background effect using Framer Motion. Built natively without heavy WebGL libraries. ## Installation ### CLI ```bash npx @modus-ui/cli add aurora-background ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/aurora-background.tsx`. 3. Paste the code. ## Usage ```tsx import { AuroraBackground } from "@/components/ui/aurora-background"; export default function Example() { return (

Hello World

); } ``` ## Props | Prop | Type | Default | Description | | :------------------- | :--------------------------------- | :------ | :--------------------------------------------- | | `children` | `ReactNode` | - | The content to render on top of the aurora. | | `colors` | `[string, string, string, string]` | `[...]` | The four colours of the aurora blobs. | | `showRadialGradient` | `boolean` | `true` | Whether to mask the bottom edge of the aurora. | ## Source `components/ui/aurora-background.tsx` ```tsx "use client"; import React from "react"; import { motion, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface AuroraBackgroundProps extends React.HTMLAttributes { children?: React.ReactNode; showRadialGradient?: boolean; /** The four blob colours. */ colors?: [string, string, string, string]; } export function AuroraBackground({ children, className, showRadialGradient = true, colors = ["#38bdf8", "#818cf8", "#34d399", "#e879f9"], ...props }: AuroraBackgroundProps) { // Reduced motion: the blobs stay at their resting positions as a still gradient. const prefersReducedMotion = useReducedMotion(); return (
{/* Animated Blob 1 */} {/* Animated Blob 2 */} {/* Animated Blob 3 */} {/* Animated Blob 4 (Extra for richness) */}
{children}
); } ``` --- # Circuit Background > A highly ambient, canvas-based circuit board animation with interactive mouse ripple effects. Install: `npx @modus-ui/cli add circuit-background` A premium HTML5 Canvas-based background animation. Technical circuit lines grow, branch off, and fade, while pointer hover and clicks trigger smooth, natural water ripples that respond to current theme modes. ## Installation ### CLI ```bash npx @modus-ui/cli add circuit-background ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/circuit-background.tsx`. 3. Paste the code. ## Usage ```tsx import { CircuitBackground } from "@/components/ui/circuit-background"; export default function Layout({ children }: { children: React.ReactNode }) { return (
{children}
); } ``` ## Props | Prop | Type | Default | Description | | :------------- | :------- | :------ | :----------------------------------------------------------------------------------- | | `maxTrails` | `number` | `60` | The maximum amount of concurrently active growing circuits. | | `lineColor` | `string` | - | Custom stroke color for circuit lines. (Defaults to theme-adapted colors). | | `circleColor` | `string` | - | Custom border color for connection node circles. (Defaults to theme-adapted colors). | | `glowStrength` | `number` | `6` | Glow shadow size in pixels. | | `circleSize` | `number` | `5` | Radius of the circuit connection nodes. | | `spawnChance` | `number` | `0.03` | Probability of spawning a new circuit line per frame. | | `className` | `string` | - | Additional CSS class for the canvas containers. | ## Source `components/ui/circuit-background.tsx` ```tsx "use client"; import React, { useEffect, useRef } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; // Self-contained ripple class for interactive mouse effects class CircuitRipple { x: number; y: number; radius: number; maxRadius: number; speed: number; alpha: number; fadeSpeed: number; thickness: number; waveOffset: number; waveSpeed: number; waveAmplitude: number; waveFrequency: number; isActive: boolean; constructor(x: number, y: number) { this.x = x; this.y = y; this.radius = 0; this.maxRadius = 80 + Math.random() * 40; this.speed = 1.2 + Math.random() * 0.3; this.alpha = 0.45; this.fadeSpeed = 0.008; this.thickness = 1.5; this.waveOffset = Math.random() * Math.PI * 2; this.waveSpeed = 0.05 + Math.random() * 0.02; this.waveAmplitude = 3 + Math.random() * 2; this.waveFrequency = 0.02 + Math.random() * 0.01; this.isActive = true; } update(ctx: CanvasRenderingContext2D, isDarkMode: boolean) { if (!this.isActive) return; this.radius += this.speed; this.alpha -= this.fadeSpeed; if (this.alpha <= 0 || this.radius > this.maxRadius) { this.isActive = false; return; } // Ripple color adapts to theme const rippleColor = isDarkMode ? "250, 249, 247" : "55, 50, 47"; for (let i = 0; i < 4; i++) { const circleRadius = this.radius - i * 6; if (circleRadius > 0) { const circleAlpha = this.alpha * (1 - i * 0.2) * 0.7; ctx.strokeStyle = `rgba(${rippleColor}, ${circleAlpha})`; ctx.lineWidth = this.thickness - i * 0.15; ctx.beginPath(); ctx.moveTo(this.x + circleRadius, this.y); for (let angle = 0; angle <= Math.PI * 2; angle += 0.1) { const waveDistortion = Math.sin(angle * this.waveFrequency + this.waveOffset) * this.waveAmplitude; const distortedRadius = circleRadius + waveDistortion; const pointX = this.x + Math.cos(angle) * distortedRadius; const pointY = this.y + Math.sin(angle) * distortedRadius; ctx.lineTo(pointX, pointY); } ctx.stroke(); } } this.waveOffset += this.waveSpeed; } } interface CircuitTrail { points: { x: number; y: number }[]; hue: number; startTime: number; growTime: number; holdTime: number; fadeTime: number; backSpawned: number; endSpawned: number; dead?: boolean; } export interface CircuitBackgroundProps { maxTrails?: number; lineColor?: string; circleColor?: string; glowStrength?: number; circleSize?: number; spawnChance?: number; className?: string; } export function CircuitBackground({ maxTrails = 60, lineColor, circleColor, glowStrength = 6, circleSize = 5, spawnChance = 0.03, className, }: CircuitBackgroundProps) { const canvasRef = useRef(null); const rippleCanvasRef = useRef(null); const trailsRef = useRef([]); const ripplesRef = useRef([]); const animationRef = useRef(null); // Reduced motion: draw one still frame of fully grown traces; no loop, drift or ripples. const prefersReducedMotion = useReducedMotion(); useEffect(() => { const isMobile = window.innerWidth < 768; if (isMobile) return; const canvas = canvasRef.current; const rippleCanvas = rippleCanvasRef.current; if (!canvas || !rippleCanvas) return; const ctx = canvas.getContext("2d", { alpha: true })!; const rippleCtx = rippleCanvas.getContext("2d", { alpha: true })!; const bufferCanvas = document.createElement("canvas"); const bufferCtx = bufferCanvas.getContext("2d", { alpha: true })!; let W: number, H: number; let running = true; // Check document class list for dark theme const isDarkMode = document.documentElement.classList.contains("dark"); // Colors mapping to Modus UI warm design palette const resolvedLineColor = lineColor || (isDarkMode ? "rgba(250, 249, 247, 0.15)" : "rgba(55, 50, 47, 0.1)"); const resolvedCircleColor = circleColor || (isDarkMode ? "rgba(250, 249, 247, 0.3)" : "rgba(55, 50, 47, 0.2)"); const allowedAngles = [0, 45, 90, 135, 180, 225, 270, 315]; const turnChoices = [45, 90]; const random = (a: number = 1, b: number = 0) => Math.random() * (a - b) + b; const choose = (arr: T[]): T => arr[Math.floor(Math.random() * arr.length)]; const normalizeDegrees = (deg: number) => ((deg % 360) + 360) % 360; function pickTurnFrom(baseDeg: number): number { const candidates = new Set(); for (const turn of turnChoices) { const angle1 = normalizeDegrees(baseDeg + turn); const angle2 = normalizeDegrees(baseDeg - turn); if (allowedAngles.includes(angle1)) candidates.add(angle1); if (allowedAngles.includes(angle2)) candidates.add(angle2); } const arr = Array.from(candidates); return arr.length ? choose(arr) : baseDeg; } function createTrail(startPoint?: { x: number; y: number }, angleDeg?: number): CircuitTrail { const start = startPoint || { x: Math.random() * bufferCanvas.width, y: Math.random() * bufferCanvas.height, }; const baseAngle = typeof angleDeg === "number" ? normalizeDegrees(angleDeg) : choose(allowedAngles); const baseRad = (baseAngle * Math.PI) / 180; const length1 = random(60, 100); const point1 = { x: start.x + Math.cos(baseRad) * length1, y: start.y + Math.sin(baseRad) * length1, }; const turnAngle = pickTurnFrom(baseAngle); const turnRad = (turnAngle * Math.PI) / 180; const length2 = random(60, 120); const point2 = { x: point1.x + Math.cos(turnRad) * length2, y: point1.y + Math.sin(turnRad) * length2, }; const length3 = random(80, 150); const point3 = { x: point2.x + Math.cos(turnRad) * length3, y: point2.y + Math.sin(turnRad) * length3, }; return { points: [start, point1, point2, point3], hue: choose([20, 30, 40, 50]), // Warm hues startTime: performance.now(), growTime: random(2000, 1200), holdTime: random(1800, 800), fadeTime: 1000, backSpawned: 0, endSpawned: 0, }; } function getTrailLength(points: { x: number; y: number }[]) { let length = 0; for (let i = 0; i < points.length - 1; i++) { length += Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y); } return length; } function drawCircle(x: number, y: number, radius: number, alpha: number) { const dpr = window.devicePixelRatio || 1; bufferCtx.globalCompositeOperation = "destination-out"; bufferCtx.beginPath(); bufferCtx.arc(x, y, radius - 1.2 * dpr, 0, Math.PI * 2); bufferCtx.fill(); bufferCtx.globalCompositeOperation = "source-over"; bufferCtx.strokeStyle = resolvedCircleColor; bufferCtx.lineWidth = 1.5 * dpr; bufferCtx.beginPath(); bufferCtx.arc(x, y, radius, 0, Math.PI * 2); bufferCtx.stroke(); } function drawTrail(trail: CircuitTrail, currentTime: number) { const elapsed = currentTime - trail.startTime; const totalLength = getTrailLength(trail.points); let alpha = 1; let progress = 0; if (elapsed < trail.growTime) { progress = elapsed / trail.growTime; alpha = progress; } else if (elapsed < trail.growTime + trail.holdTime) { progress = 1; } else if (elapsed < trail.growTime + trail.holdTime + trail.fadeTime) { progress = 1; alpha = 1 - (elapsed - trail.growTime - trail.holdTime) / trail.fadeTime; } else { trail.dead = true; return; } alpha *= 0.25; // Keep it subtle and ambient const drawLength = totalLength * progress; bufferCtx.strokeStyle = resolvedLineColor; bufferCtx.lineWidth = 1.2 * (window.devicePixelRatio || 1); bufferCtx.lineCap = "round"; bufferCtx.shadowBlur = glowStrength * (window.devicePixelRatio || 1); bufferCtx.shadowColor = resolvedLineColor; bufferCtx.beginPath(); bufferCtx.moveTo(trail.points[0].x, trail.points[0].y); let remaining = drawLength; for (let i = 0; i < trail.points.length - 1; i++) { const pointA = trail.points[i]; const pointB = trail.points[i + 1]; const segmentLength = Math.hypot(pointB.x - pointA.x, pointB.y - pointA.y); if (remaining >= segmentLength) { bufferCtx.lineTo(pointB.x, pointB.y); remaining -= segmentLength; } else { const t = remaining / segmentLength; bufferCtx.lineTo( pointA.x + (pointB.x - pointA.x) * t, pointA.y + (pointB.y - pointA.y) * t, ); break; } } bufferCtx.stroke(); drawCircle( trail.points[0].x, trail.points[0].y, circleSize * (window.devicePixelRatio || 1), alpha, ); if (progress >= 1) { drawCircle( trail.points[3].x, trail.points[3].y, circleSize * (window.devicePixelRatio || 1), alpha, ); // Branching logic at ends if (!trail.endSpawned && trailsRef.current.length < maxTrails && Math.random() < 0.1) { trail.endSpawned = 1; const dx = trail.points[3].x - trail.points[2].x; const dy = trail.points[3].y - trail.points[2].y; const parentAngle = normalizeDegrees((Math.atan2(dy, dx) * 180) / Math.PI); const turnAngle = pickTurnFrom(parentAngle); if (turnAngle !== parentAngle) { trailsRef.current.push(createTrail(trail.points[3], turnAngle)); } } } } function resize() { W = window.innerWidth; H = window.innerHeight; canvas!.width = W; canvas!.height = H; rippleCanvas!.width = W; rippleCanvas!.height = H; bufferCanvas.width = W * 1.5; bufferCanvas.height = H * 1.5; } function drawStatic() { bufferCtx.setTransform(1, 0, 0, 1, 0, 0); bufferCtx.clearRect(0, 0, bufferCanvas.width, bufferCanvas.height); trailsRef.current = Array.from({ length: Math.min(12, maxTrails) }, () => createTrail()); for (const trail of trailsRef.current) { drawTrail(trail, trail.startTime + trail.growTime); } ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, W, H); ctx.drawImage(bufferCanvas, -W * 0.25, -H * 0.25, W * 1.5, H * 1.5); } if (prefersReducedMotion) { const handleStaticResize = () => { resize(); drawStatic(); }; handleStaticResize(); window.addEventListener("resize", handleStaticResize); return () => { window.removeEventListener("resize", handleStaticResize); trailsRef.current = []; }; } function animate(currentTime: number) { if (!running) return; bufferCtx.setTransform(1, 0, 0, 1, 0, 0); bufferCtx.clearRect(0, 0, bufferCanvas.width, bufferCanvas.height); if (Math.random() < spawnChance && trailsRef.current.length < maxTrails) { trailsRef.current.push(createTrail()); } trailsRef.current = trailsRef.current.filter((trail) => { drawTrail(trail, currentTime); return !trail.dead; }); // Render camera drift/perspective zoom ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, W, H); const driftX = Math.sin(currentTime * 0.0001) * 80; const driftY = Math.cos(currentTime * 0.00008) * 50; ctx.drawImage(bufferCanvas, -W * 0.25 + driftX, -H * 0.25 + driftY, W * 1.5, H * 1.5); // Ripple drawing rippleCtx.clearRect(0, 0, rippleCanvas!.width, rippleCanvas!.height); ripplesRef.current = ripplesRef.current.filter((ripple) => { ripple.update(rippleCtx, isDarkMode); return ripple.isActive; }); animationRef.current = requestAnimationFrame(animate); } const handleMouseMove = (e: MouseEvent) => { if (Math.random() < 0.3) { ripplesRef.current.push(new CircuitRipple(e.clientX, e.clientY)); } }; const handleClick = (e: MouseEvent) => { const x = e.clientX; const y = e.clientY; for (let i = 0; i < 4; i++) { const ripple = new CircuitRipple( x + (Math.random() - 0.5) * 20, y + (Math.random() - 0.5) * 20, ); ripple.maxRadius = 110 + Math.random() * 40; ripple.speed = 1.8 + Math.random() * 0.8; ripplesRef.current.push(ripple); } }; resize(); window.addEventListener("resize", resize); window.addEventListener("mousemove", handleMouseMove); window.addEventListener("click", handleClick); for (let i = 0; i < 3; i++) { trailsRef.current.push(createTrail()); } animationRef.current = requestAnimationFrame(animate); return () => { running = false; if (animationRef.current) cancelAnimationFrame(animationRef.current); window.removeEventListener("resize", resize); window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("click", handleClick); trailsRef.current = []; ripplesRef.current = []; }; }, [ maxTrails, lineColor, circleColor, glowStrength, circleSize, spawnChance, prefersReducedMotion, ]); return ( <> ); } ``` --- # Orbiting Elements > Elements that mathematically orbit around a central node in circular paths. Install: `npx @modus-ui/cli add orbiting-elements` A beautiful physics-driven layout component. It places child elements evenly around a circle and rotates them around a center point. Crucially, each item counter-rotates at the exact same speed, ensuring icons and text never end up upside down as they travel the orbit. ## Installation ### CLI ```bash npx @modus-ui/cli add orbiting-elements ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/orbiting-elements.tsx`. 3. Paste the code. ## Usage ```tsx import { OrbitingElements } from "@/components/ui/orbiting-elements"; export default function Example() { return (
); } ``` ## Props | Prop | Type | Default | Description | | :----------- | :-------- | :------ | :-------------------------- | | `radius` | `number` | `100` | Orbit radius in pixels. | | `duration` | `number` | `20` | Seconds for one full orbit. | | `reverse` | `boolean` | `false` | Orbits counter-clockwise. | | `showPath` | `boolean` | `true` | Draws the orbit path. | | `startAngle` | `number` | `0` | Starting angle in degrees. | ## Source `components/ui/orbiting-elements.tsx` ```tsx "use client"; import { Children, type ReactNode } from "react"; import { motion, useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; interface OrbitingElementsProps { children: ReactNode; radius?: number; duration?: number; reverse?: boolean; showPath?: boolean; startAngle?: number; className?: string; } export function OrbitingElements({ children, radius = 100, duration = 20, reverse = false, showPath = true, startAngle = 0, className = "", }: OrbitingElementsProps) { const items = Children.toArray(children); const prefersReducedMotion = useReducedMotion(); const spin = reverse ? -360 : 360; const transition = { duration, repeat: Infinity, ease: "linear" as const, }; return (
{showPath ? ( ); } ``` --- # Gloop Trail > A pooled ink trail that follows the pointer, shading from the theme ink toward muted grey. Install: `npx @modus-ui/cli add gloop-trail` An ink trail that follows the pointer. Each step of the trail shades from your theme ink toward the muted token. It uses a custom SVG `feGaussianBlur` and `feColorMatrix` filter to blend multiple trailing DOM elements into a single fluid "pool" that follows your cursor physics. ## Installation ### CLI ```bash npx @modus-ui/cli add gloop-trail ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/gloop-trail.tsx`. 3. Paste the code. ## Usage ```tsx import { GloopTrail } from "@/components/ui/gloop-trail"; export default function Example() { return (
); } ``` ## Props | Prop | Type | Default | Description | | :---------- | :--------------------------------- | :---------------------- | :----------------------------------- | | `blobType` | `"circle" \| "square" \| "almond"` | `"circle"` | Shape of the trailing drops. | | `fillColor` | `string` | `"hsl(var(--primary))"` | Ink colour at the head of the trail. | ## Source `components/ui/gloop-trail.tsx` ```tsx "use client"; // Drops that chase the pointer at different speeds and melt into one pool through a goo filter. import { useState, useEffect, useId, useRef } from "react"; import { motion, useMotionValue, useReducedMotion, useSpring, type MotionValue, } from "framer-motion"; type BlobType = "circle" | "square" | "almond"; interface GloopTrailProps { blobType?: BlobType; fillColor?: string; } const DROPS = 4; const RADII: Record = { circle: "50%", square: "22%", almond: "50% 0" }; function Drop({ x, y, index, blobType, fillColor, still, }: { x: MotionValue; y: MotionValue; index: number; blobType: BlobType; fillColor: string; still: boolean; }) { const spring = { stiffness: 520 - index * 110, damping: 28 + index * 4, mass: 0.6 }; const sx = useSpring(x, spring); const sy = useSpring(y, spring); const size = 64 - index * 10; return ( ); } export function GloopTrail({ blobType = "circle", fillColor = "hsl(var(--primary))", }: GloopTrailProps) { const prefersReduced = useReducedMotion(); // Follow the setting only after hydration, so the first render matches the server's. const [reduce, setReduce] = useState(false); useEffect(() => setReduce(Boolean(prefersReduced)), [prefersReduced]); const filterId = `gloop-${useId().replace(/:/g, "")}`; const layer = useRef(null); const x = useMotionValue(-200); const y = useMotionValue(-200); useEffect(() => { const onMove = (e: PointerEvent) => { const box = layer.current?.getBoundingClientRect(); if (!box) return; x.set(e.clientX - box.left); y.set(e.clientY - box.top); }; window.addEventListener("pointermove", onMove); return () => window.removeEventListener("pointermove", onMove); }, [x, y]); return (
{Array.from({ length: reduce ? 1 : DROPS }, (_, i) => ( ))}
); } ``` --- # Iron Filings > A field of fine ink filings that turn to face the pointer, like iron around a lodestone. Install: `npx @modus-ui/cli add iron-filings` A field of fine filings in alternating ink and accent. It renders a CSS grid of simple `` elements and uses a single window event listener to calculate the geometric angle (using `Math.acos()`) from each line to your mouse, applying the rotation via CSS custom properties. ## Installation ### CLI ```bash npx @modus-ui/cli add iron-filings ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/iron-filings.tsx`. 3. Paste the code. ## Usage ```tsx import { IronFilings } from "@/components/ui/iron-filings"; export default function Example() { return (
); } ``` ## Props | Prop | Type | Default | Description | | :-------------- | :------- | :------------------------- | :---------------------------------------- | | `rows` | `number` | `13` | Number of rows of lines. | | `columns` | `number` | `13` | Number of columns of lines. | | `containerSize` | `string` | `"70vmin"` | CSS size of the square container. | | `lineColor` | `string` | `"hsl(var(--foreground))"` | Colour of the lines. | | `lineWidth` | `string` | `"0.4vmin"` | CSS width of each line. | | `lineHeight` | `string` | `"3.6vmin"` | CSS length of each line. | | `baseAngle` | `number` | `45` | Resting rotation of the lines in degrees. | ## Source `components/ui/iron-filings.tsx` ```tsx "use client"; // A grid of short strokes that swing to point at the cursor. import { useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; interface IronFilingsProps { rows?: number; columns?: number; containerSize?: string; lineColor?: string; lineWidth?: string; lineHeight?: string; baseAngle?: number; className?: string; } export function IronFilings({ rows = 13, columns = 13, containerSize = "70vmin", lineColor = "hsl(var(--foreground))", lineWidth = "0.4vmin", lineHeight = "3.6vmin", baseAngle = 45, className, }: IronFilingsProps) { const root = useRef(null); useEffect(() => { let frame = 0; let pointer: { x: number; y: number } | null = null; const aim = () => { frame = 0; if (!pointer || !root.current) return; for (const el of root.current.children as HTMLCollectionOf) { const r = el.getBoundingClientRect(); const deg = (Math.atan2(pointer.y - (r.top + r.height / 2), pointer.x - (r.left + r.width / 2)) * 180) / Math.PI + 90; el.style.setProperty("--aim", `${deg}deg`); } }; const onMove = (e: PointerEvent) => { pointer = { x: e.clientX, y: e.clientY }; if (!frame) frame = requestAnimationFrame(aim); }; window.addEventListener("pointermove", onMove); return () => { window.removeEventListener("pointermove", onMove); cancelAnimationFrame(frame); }; }, []); return (
{Array.from({ length: rows * columns }, (_, i) => ( ))}
); } ``` --- # Grid Motion > An interactive, infinite grid that tracks mouse movement with beautiful inertia physics. Install: `npx @modus-ui/cli add grid-motion` An interactive, infinite grid that tracks mouse movement with beautiful inertia physics. Perfect for hero sections or showcasing features and partner logos. ## Installation ### CLI ```bash npx @modus-ui/cli add grid-motion ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/grid-motion.tsx`. 3. Paste the code. ## Usage ```tsx import { GridMotion } from "@/components/ui/grid-motion"; export default function Example() { const items = [ "Design", "Development", "Engineering", "Architecture", "Performance", "Accessibility", "Framer Motion", "Tailwind", ]; return (
); } ``` ## Props | Prop | Type | Default | Description | | :-------------- | :------------ | :------------------------- | :----------------------------------------------------------------------------------------------- | | `items` | `ReactNode[]` | `[]` | Array of items to populate the grid. Should ideally have at least 28 items to fill the viewport. | | `gradientColor` | `string` | `"hsl(var(--background))"` | Center color of the radial gradient mask overlay. | ## Source `components/ui/grid-motion.tsx` ```tsx "use client"; // A tilted wall of tiles whose rows drift with the pointer, each row on its own lagging spring. import { useEffect } from "react"; import type React from "react"; import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue, } from "framer-motion"; import { cn } from "@/lib/utils"; const ROWS = 4; const COLS = 7; const TRAVEL = 220; export interface GridMotionProps { items?: React.ReactNode[]; gradientColor?: string; className?: string; } function Row({ pointer, index, cells, }: { pointer: MotionValue; index: number; cells: React.ReactNode[]; }) { const direction = index % 2 === 0 ? 1 : -1; const target = useTransform(pointer, (p) => p * TRAVEL * direction); const x = useSpring(target, { stiffness: 40 - index * 7, damping: 18, mass: 1 + index * 0.3 }); return ( {cells.map((cell, i) => (
{cell}
))}
); } export function GridMotion({ items = [], gradientColor = "hsl(var(--background))", className, }: GridMotionProps) { const reduceMotion = useReducedMotion(); const pointer = useMotionValue(0); useEffect(() => { if (reduceMotion) return; const onMove = (e: PointerEvent) => pointer.set((e.clientX / window.innerWidth) * 2 - 1); window.addEventListener("pointermove", onMove); return () => window.removeEventListener("pointermove", onMove); }, [pointer, reduceMotion]); const cells = Array.from({ length: ROWS * COLS }, (_, i) => items.length ? items[i % items.length] : null, ); return (
{Array.from({ length: ROWS }, (_, r) => ( ))}
); } ``` --- # Moon Lattice > A centred WebGL globe of noise-carved points, tilted and threaded with thin rings, inked in the theme colours. Install: `npx @modus-ui/cli add moon-lattice` A Three.js point globe carved by noise and tilted on its axis, wrapped in six thin rings that drift in opposite directions. Colours come from the theme: `--primary` for the bright points and nodes, `--muted-foreground` for the dim ones and `--foreground` for the rings. In light mode it is drawn as ink on paper; in dark mode it glows additively. It follows palette and mode changes live. It fills its positioned parent, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add moon-lattice ``` ### Manual 1. Install `three`. 2. Copy the code from the component tab. 3. Create a file at `components/ui/moon-lattice.tsx`. 4. Paste the code. ## Usage ```tsx import { MoonLattice } from "@/components/ui/moon-lattice"; export default function Example() { return (

Content over the effect

); } ``` ## Props | Prop | Type | Default | Description | | :----------------- | :------- | :------------------------- | :------------------------------------ | | `speed` | `number` | `1` | Rotation speed multiplier. | | `particleSize` | `number` | `0.022` | Size of each particle. | | `particleOpacity` | `number` | `0.8` | Opacity of the particles. | | `orbitOpacity` | `number` | `0.35` | Opacity of the rings. | | `scale` | `number` | `1` | Scale of the whole sphere. | | `haloOpacity` | `number` | `0.2` | Opacity of the glow around each node. | | `hue` | `number` | `0` | Hue rotation in degrees. | | `particleColor` | `string` | theme `--primary` | Colour of the bright particles. | | `particleDimColor` | `string` | theme `--muted-foreground` | Colour of the dim particles. | | `orbitColor` | `string` | theme `--foreground` | Colour of the orbit rings. | | `nodeColor` | `string` | theme `--primary` | Colour of the orbiting nodes. | | `haloColor` | `string` | theme `--primary` | Colour of the node halos. | | `backgroundColor` | `string` | theme `--background` | Background behind the sphere. | ## Source `components/ui/moon-lattice.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Orbital Sphere (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import * as THREE from "three"; import { cn } from "@/lib/utils"; /** Reads a bare-HSL theme token as an `hsl(h, s%, l%)` string three.js can parse. */ const themeColor = (name: string) => `hsl(${getComputedStyle(document.documentElement).getPropertyValue(name).trim().split(/\s+/).join(", ")})`; const isLightTheme = () => Number.parseFloat( getComputedStyle(document.documentElement) .getPropertyValue("--background") .trim() .split(/\s+/)[2] ?? "0", ) > 50; export interface MoonLatticeProps { speed?: number; particleSize?: number; particleOpacity?: number; orbitOpacity?: number; scale?: number; haloOpacity?: number; hue?: number; particleColor?: string; particleDimColor?: string; orbitColor?: string; nodeColor?: string; haloColor?: string; backgroundColor?: string; className?: string; } export function MoonLattice({ speed = 1, particleSize = 0.022, particleOpacity = 0.8, orbitOpacity = 0.35, scale = 1, haloOpacity = 0.2, hue = 0, particleColor, particleDimColor, orbitColor, nodeColor, haloColor, backgroundColor, className, }: MoonLatticeProps) { const prefersReducedMotion = useReducedMotion(); const [themeKey, setThemeKey] = useState(0); const hostRef = useRef(null); const canvasRef = useRef(null); const options = useRef({ speed, particleSize, particleOpacity, orbitOpacity, scale, haloOpacity, }); options.current = { speed, particleSize, particleOpacity, orbitOpacity, scale, haloOpacity }; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; if (!host || !canvas) return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000); const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // The original ran on three r128 (no colour management): colours reach the screen untouched. renderer.outputColorSpace = THREE.LinearSRGBColorSpace; const raw = (value: string) => new THREE.Color().setStyle(value, THREE.LinearSRGBColorSpace); const light = isLightTheme(); const blending = light ? THREE.NormalBlending : THREE.AdditiveBlending; const networkGroup = new THREE.Group(); scene.add(networkGroup); const radius = 2.2; const particleCount = 15000; const positions = new Float32Array(particleCount * 3); const colors = new Float32Array(particleCount * 3); const colorBright = raw(particleColor ?? themeColor("--primary")); const colorDim = raw(particleDimColor ?? themeColor("--muted-foreground")); let validIndex = 0; for (let index = 0; index < particleCount; index += 1) { const phi = Math.acos(-1 + (2 * index) / particleCount); const theta = Math.sqrt(particleCount * Math.PI) * phi; const x = radius * Math.cos(theta) * Math.sin(phi); const y = radius * Math.sin(theta) * Math.sin(phi); const z = radius * Math.cos(phi); const noise = Math.sin(x * 3.5) * Math.cos(y * 3.5) * Math.sin(z * 3.5) + Math.cos(x * 6) * 0.4; if (noise <= -0.1) continue; const distortion = 1 + noise * 0.1; positions[validIndex * 3] = x * distortion; positions[validIndex * 3 + 1] = y * distortion; positions[validIndex * 3 + 2] = z * distortion; const mixedColor = colorDim.clone().lerp(colorBright, noise > 0.5 ? 1 : 0.3); colors[validIndex * 3] = mixedColor.r; colors[validIndex * 3 + 1] = mixedColor.g; colors[validIndex * 3 + 2] = mixedColor.b; validIndex += 1; } const particleGeometry = new THREE.BufferGeometry(); particleGeometry.setAttribute( "position", new THREE.BufferAttribute(positions.slice(0, validIndex * 3), 3), ); particleGeometry.setAttribute( "color", new THREE.BufferAttribute(colors.slice(0, validIndex * 3), 3), ); const particleMaterial = new THREE.PointsMaterial({ size: 0.015, vertexColors: true, transparent: true, opacity: 0.8, blending, depthWrite: false, }); networkGroup.add(new THREE.Points(particleGeometry, particleMaterial)); const orbitMaterial = new THREE.LineBasicMaterial({ color: raw(orbitColor ?? themeColor("--foreground")), transparent: true, opacity: 0.25, blending, }); const orbitGeometries: THREE.BufferGeometry[] = []; const nodeGeometries: THREE.BufferGeometry[] = []; const nodeMaterials: THREE.Material[] = []; const haloMaterials: THREE.MeshBasicMaterial[] = []; let responsiveScale = 1; for (let index = 0; index < 6; index += 1) { const geometry = new THREE.BufferGeometry(); const points: number[] = []; const orbitRadius = radius * (1.08 + Math.random() * 0.2); for (let point = 0; point <= 90; point += 1) { const angle = (point / 90) * Math.PI * 2; points.push( Math.cos(angle) * orbitRadius, Math.sin(angle) * orbitRadius, Math.sin(angle * 4) * 0.1, ); } geometry.setAttribute("position", new THREE.Float32BufferAttribute(points, 3)); orbitGeometries.push(geometry); const line = new THREE.Line(geometry, orbitMaterial); line.rotation.x = Math.random() * Math.PI * 2; line.rotation.y = Math.random() * Math.PI * 2; networkGroup.add(line); if (index % 2 !== 0) { const nodeGeometry = new THREE.SphereGeometry(0.025, 16, 16); const nodeMaterial = new THREE.MeshBasicMaterial({ color: raw(nodeColor ?? themeColor("--primary")), }); const node = new THREE.Mesh(nodeGeometry, nodeMaterial); const angle = Math.random() * Math.PI * 2; node.position.set(Math.cos(angle) * orbitRadius, Math.sin(angle) * orbitRadius, 0); line.add(node); const haloGeometry = new THREE.SphereGeometry(0.08, 16, 16); const haloMaterial = new THREE.MeshBasicMaterial({ color: raw(haloColor ?? themeColor("--primary")), transparent: true, opacity: 0.2, blending, }); node.add(new THREE.Mesh(haloGeometry, haloMaterial)); nodeGeometries.push(nodeGeometry, haloGeometry); nodeMaterials.push(nodeMaterial, haloMaterial); haloMaterials.push(haloMaterial); } } const render = (advance: boolean) => { const o = options.current; particleMaterial.size = o.particleSize; particleMaterial.opacity = o.particleOpacity; orbitMaterial.opacity = o.orbitOpacity; haloMaterials.forEach((material) => (material.opacity = o.haloOpacity)); networkGroup.scale.setScalar(responsiveScale * o.scale); if (advance) { networkGroup.rotation.y += 0.0008 * o.speed; networkGroup.rotation.x += 0.0003 * o.speed; networkGroup.children.forEach((child, index) => { if (child.type === "Line") child.rotation.z += 0.0004 * o.speed * (index % 2 === 0 ? 1 : -1); }); } renderer.render(scene, camera); }; const resize = () => { const { width, height } = host.getBoundingClientRect(); camera.aspect = width / Math.max(1, height); camera.updateProjectionMatrix(); renderer.setSize(width, height, false); if (width >= 1024) { networkGroup.position.set(0, 0, -1.5); responsiveScale = 0.95; camera.position.z = 6; } else { networkGroup.position.set(0, 0, -3); responsiveScale = 1; camera.position.z = 6.5; } render(false); }; let frame = 0; let visible = true; const tick = () => { render(true); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const start = () => { if (!prefersReducedMotion && visible && !frame) frame = requestAnimationFrame(tick); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) start(); else if (frame) { cancelAnimationFrame(frame); frame = 0; } }); const onVisibility = () => !document.hidden && start(); document.addEventListener("visibilitychange", onVisibility); resizeObserver.observe(host); intersection.observe(host); networkGroup.rotation.set(0.5, 0, 0.35); // Re-run the scene when the site switches light/dark mode or palette. const themeObserver = new MutationObserver(() => setThemeKey((key) => key + 1)); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme", "data-palette"], }); resize(); start(); return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); themeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); particleGeometry.dispose(); particleMaterial.dispose(); orbitMaterial.dispose(); orbitGeometries.forEach((item) => item.dispose()); nodeGeometries.forEach((item) => item.dispose()); nodeMaterials.forEach((item) => item.dispose()); renderer.dispose(); }; }, [ prefersReducedMotion, themeKey, particleColor, particleDimColor, orbitColor, nodeColor, haloColor, ]); return (
); } ``` --- # Resonance Plate > Chladni-style nodal lines shimmering across an off-centre plate, with drifting motes, in theme ink. Install: `npx @modus-ui/cli add resonance-plate` A WebGL shader draws the shifting nodal lines of a vibrating plate set off to the right, leaving the left side calm for copy. Nodal lines take the muted ink, antinodes and the periodic strike ring take the primary, and a few motes drift upward. Click to strike it. It fills its positioned parent, follows the active theme and palette in light and dark mode, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add resonance-plate ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/resonance-plate.tsx`. 3. Paste the code. ## Usage ```tsx import { ResonancePlate } from "@/components/ui/resonance-plate"; export default function Example() { return (

Content over the background

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :--------- | :---------- | :------------------------------------------------------------------------------ | | `speed` | `number` | `1` | Animation speed multiplier. | | `pointerAmount` | `number` | `1` | How far the pattern follows the pointer. | | `strikeDuration` | `number` | `2400` | Milliseconds a strike's ring takes to fade. | | `emberAmount` | `number` | `1` | Share of the 58 drifting motes to draw (0 to 1). | | `brightness` | `number` | `1` | CSS brightness filter. | | `opacity` | `number` | `1` | Overall opacity. | | `hue` | `number` | `0` | Hue rotation in degrees. | | `saturation` | `number` | `1` | CSS saturation filter. | | `backgroundColor` | `string` | `undefined` | CSS background. Defaults to the theme background. | | `colors` | `string[]` | `undefined` | Hex overrides for deep, node, antinode and highlight. Defaults to theme tokens. | | `emberColors` | `string[]` | `undefined` | Hex overrides for hot and cool motes. Defaults to theme tokens. | ## Source `components/ui/resonance-plate.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Bell Field (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface ResonancePlateProps { speed?: number; pointerAmount?: number; strikeDuration?: number; emberAmount?: number; brightness?: number; opacity?: number; hue?: number; saturation?: number; /** CSS background. Defaults to the theme background. */ backgroundColor?: string; /** Hex overrides for [deep, node, antinode, highlight]. Defaults to theme tokens. */ colors?: string[]; /** Hex overrides for [hot, cool] motes. Defaults to theme tokens. */ emberColors?: string[]; className?: string; } const TOKENS = [ "primary", "foreground", "background", "muted-foreground", "border", "card", ] as const; type Tokens = Record<(typeof TOKENS)[number], string>; /** Bare HSL channels of the Modus theme tokens, re-read when the theme or palette changes. */ function useThemeTokens(): Tokens | null { const [tokens, setTokens] = useState(null); useEffect(() => { const root = document.documentElement; const read = () => { const style = getComputedStyle(root); const next = Object.fromEntries( TOKENS.map((name) => [name, style.getPropertyValue(`--${name}`).trim()]), ) as Tokens; setTokens((prev) => (prev && TOKENS.every((k) => prev[k] === next[k]) ? prev : next)); }; read(); const observer = new MutationObserver(read); observer.observe(root, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return tokens; } /** Bare HSL channels ("h s% l%") to 0–1 RGB. */ function channelsToRgb(channels: string): [number, number, number] { const [h, s, l] = channels.split(/\s+/).map((part) => parseFloat(part)); const sat = (s || 0) / 100; const lig = (l || 0) / 100; const k = (n: number) => (n + (h || 0) / 30) % 12; const a = sat * Math.min(lig, 1 - lig); const f = (n: number) => lig - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); return [f(0), f(8), f(4)]; } const EMBER_COUNT = 58; const VERTEX_SHADER = ` attribute vec2 position; void main() { gl_Position = vec4(position, 0.0, 1.0); } `; const FRAGMENT_SHADER = ` precision highp float; uniform vec2 u_resolution; uniform float u_time; uniform vec2 u_mouse; uniform float u_strike; uniform vec3 u_deep; uniform vec3 u_patina; uniform vec3 u_bronze; uniform vec3 u_ash; #define PI 3.14159265359 float hash(vec2 p) { return fract(sin(dot(p, vec2(23.71, 91.37))) * 41537.1234); } // damped-cosine stand-in for the Bessel envelope of a circular mode float bess(float x) { return cos(x - 0.785398) / sqrt(1.0 + abs(x)); } void main() { vec2 uv = gl_FragCoord.xy / u_resolution.xy; vec2 p = uv * 2.0 - 1.0; p.x *= u_resolution.x / u_resolution.y; // The plate sits off-centre, to the right, leaving the left side calm for copy. p.x -= 0.62 * u_resolution.x / u_resolution.y; p.y += 0.12; vec2 m = u_mouse / u_resolution.xy * 2.0 - 1.0; m.y = -m.y; m.x *= u_resolution.x / u_resolution.y; p -= m * 0.11; float t = u_time * 0.09; float r = length(p); float a = atan(p.y, p.x); // the bell drifts between partials the way a struck bell does float ang = 3.0 + 1.6 * sin(t * 0.37) + sin(t * 0.19 + 1.7); float k = 3.1 + 1.0 * sin(t * 0.23 + 0.6); float amp = 1.0 + (1.0 - u_strike) * 0.55; float f1 = bess(r * k * PI - t * 2.2) * cos(ang * a + t * 0.5); float f2 = bess(r * k * 1.6 * PI + t * 1.4) * cos((ang * 2.0 + 1.0) * a - t * 0.31); float f = (f1 + f2 * 0.30) * amp; // nodal lines — where the metal stands still float node = 1.0 - smoothstep(0.0, 0.075 + 0.075 * r, abs(f)); // antinodes — where it moves, and glows hot float anti = smoothstep(0.40, 0.95, abs(f)); // the crown stays quiet — clears a reading zone under the type float open = smoothstep(0.14, 0.92, r); node *= open; anti *= open; vec3 col = u_deep; col = mix(col, u_patina, node * 0.50); col = mix(col, u_bronze, anti * 0.55); col = mix(col, u_ash, pow(node, 3.0) * 0.25); // shock ring travelling out from the strike float ring = smoothstep(0.06, 0.0, abs(r - u_strike * 2.3)) * (1.0 - u_strike); col = mix(col, u_bronze, ring * 0.6); col = mix(u_deep, col, smoothstep(2.2, 0.3, r)); col += (hash(gl_FragCoord.xy) - 0.5) * 0.022; gl_FragColor = vec4(col, 1.0); } `; type Ember = { x: number; y: number; r: number; vy: number; vx: number; ph: number; sp: number; hot: boolean; }; function compile(gl: WebGLRenderingContext, type: number, source: string) { const shader = gl.createShader(type); if (!shader) throw new Error("Unable to create Resonance Plate shader"); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error(gl.getShaderInfoLog(shader) ?? "Resonance Plate shader compilation failed"); } return shader; } function hexToRgb(hex: string): [number, number, number] { const value = parseInt(hex.replace("#", ""), 16); return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255]; } export function ResonancePlate({ speed = 1, pointerAmount = 1, strikeDuration = 2400, emberAmount = 1, brightness = 1, opacity = 1, hue = 0, saturation = 1, backgroundColor, colors, emberColors, className, }: ResonancePlateProps) { const tokens = useThemeTokens(); const hostRef = useRef(null); const modeRef = useRef(null); const emberRef = useRef(null); const prefersReducedMotion = useReducedMotion(); const optionsRef = useRef({ speed, pointerAmount, strikeDuration, emberAmount, colors, emberColors, tokens, }); optionsRef.current = { speed, pointerAmount, strikeDuration, emberAmount, colors, emberColors, tokens, }; useEffect(() => { const host = hostRef.current; const canvas = modeRef.current; const emberCanvas = emberRef.current; if (!host || !canvas || !emberCanvas) return; const gl = canvas.getContext("webgl"); const emberContext = emberCanvas.getContext("2d"); if (!gl || !emberContext) return; const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER); const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); const program = gl.createProgram(); if (!program) return; gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error(gl.getProgramInfoLog(program) ?? "Resonance Plate program link failed"); } gl.useProgram(program); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, -1, 1, 1, -1, 1]), gl.STATIC_DRAW, ); const position = gl.getAttribLocation(program, "position"); gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); const resolution = gl.getUniformLocation(program, "u_resolution"); const time = gl.getUniformLocation(program, "u_time"); const mouseUniform = gl.getUniformLocation(program, "u_mouse"); const strikeUniform = gl.getUniformLocation(program, "u_strike"); const colorUniforms = ["u_deep", "u_patina", "u_bronze", "u_ash"].map((name) => gl.getUniformLocation(program, name), ); let width = 1; let height = 1; let dpr = 1; let mouseX = 0.5; let mouseY = 0.5; let targetX = 0.5; let targetY = 0.5; let frame = 0; let visible = true; let initialized = false; let lastStrikeMs = -1e9; const startedAt = performance.now(); const embers: Ember[] = Array.from({ length: EMBER_COUNT }, () => ({ x: Math.random(), y: Math.random(), r: 0.4 + Math.random() * 1.4, vy: -(0.1 + Math.random() * 0.26), vx: (Math.random() - 0.5) * 0.08, ph: Math.random() * Math.PI * 2, sp: 0.5 + Math.random() * 1.4, hot: Math.random() < 0.36, })); const draw = (now: number) => { const options = optionsRef.current; const t = now * 0.001 * options.speed; mouseX += (targetX - mouseX) * 0.04; mouseY += (targetY - mouseY) * 0.04; gl.uniform1f(time, (now - startedAt) * 0.001 * options.speed); gl.uniform1f( strikeUniform, Math.min(1, Math.max(0, (now - lastStrikeMs) / options.strikeDuration)), ); gl.uniform2f(mouseUniform, mouseX * dpr, mouseY * dpr); const tk = options.tokens; const themed = tk ? [tk.background, tk["muted-foreground"], tk.primary, tk.foreground] : null; colorUniforms.forEach((location, index) => { const override = options.colors?.[index]; const channels = themed?.[index]; const [r, g, b] = override ? hexToRgb(override) : channels ? channelsToRgb(channels) : [0.5, 0.5, 0.5]; gl.uniform3f(location, r, g, b); }); gl.drawArrays(gl.TRIANGLES, 0, 6); const moteRgb = (index: number, fallback: string | undefined) => { const override = options.emberColors?.[index]; const [r, g, b] = override ? hexToRgb(override) : fallback ? channelsToRgb(fallback) : [0.5, 0.5, 0.5]; return `${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}`; }; const hot = moteRgb(0, tk?.primary); const cool = moteRgb(1, tk?.["muted-foreground"]); emberContext.clearRect(0, 0, width, height); const count = Math.max( 0, Math.min(EMBER_COUNT, Math.round(EMBER_COUNT * options.emberAmount)), ); for (let index = 0; index < count; index += 1) { const ember = embers[index]; if (!prefersReducedMotion) { ember.y += ember.vy * options.speed; ember.x += (ember.vx + Math.sin(t * ember.sp * 0.5 + ember.ph) * 0.13) * options.speed; } if (ember.y < -4) { ember.y = height + 4; ember.x = Math.random() * width; } if (ember.x < -4) ember.x = width + 4; if (ember.x > width + 4) ember.x = -4; const twinkle = 0.5 + 0.5 * Math.sin(t * ember.sp + ember.ph); emberContext.beginPath(); emberContext.arc(ember.x, ember.y, ember.r, 0, Math.PI * 2); emberContext.fillStyle = ember.hot ? `rgba(${hot}, ${0.06 + twinkle * 0.34})` : `rgba(${cool}, ${0.04 + twinkle * 0.24})`; emberContext.fill(); } }; const render = (now: number) => { draw(now); frame = visible && !document.hidden ? requestAnimationFrame(render) : 0; }; const resize = () => { const bounds = host.getBoundingClientRect(); width = Math.max(1, bounds.width); height = Math.max(1, bounds.height); dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.max(1, Math.round(width * dpr)); canvas.height = Math.max(1, Math.round(height * dpr)); emberCanvas.width = canvas.width; emberCanvas.height = canvas.height; emberContext.setTransform(dpr, 0, 0, dpr, 0, 0); gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform2f(resolution, canvas.width, canvas.height); if (!initialized) { mouseX = targetX = width * 0.5; mouseY = targetY = height * 0.5; initialized = true; } embers.forEach((ember) => { if (ember.x <= 1) ember.x *= width; if (ember.y <= 1) ember.y *= height; }); if (prefersReducedMotion) draw(startedAt + 14000 / Math.max(speed, 0.001)); }; const inside = (event: PointerEvent) => { const bounds = host.getBoundingClientRect(); return ( event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom ); }; const pointer = (event: PointerEvent) => { if (!inside(event)) return; const bounds = host.getBoundingClientRect(); const amount = optionsRef.current.pointerAmount; targetX = width * 0.5 + (event.clientX - bounds.left - width * 0.5) * amount; targetY = height * 0.5 + (event.clientY - bounds.top - height * 0.5) * amount; }; const strike = () => { lastStrikeMs = performance.now(); }; const pointerDown = (event: PointerEvent) => { if (inside(event)) strike(); }; const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host); resize(); if (prefersReducedMotion) { return () => { resizeObserver.disconnect(); gl.deleteBuffer(buffer); gl.deleteShader(vertex); gl.deleteShader(fragment); gl.deleteProgram(program); }; } const firstStrike = window.setTimeout(strike, 1700); const strikeTimer = window.setInterval(strike, 8200); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible && !frame) frame = requestAnimationFrame(render); if (!visible && frame) { cancelAnimationFrame(frame); frame = 0; } }); intersection.observe(host); window.addEventListener("pointermove", pointer, { passive: true }); window.addEventListener("pointerdown", pointerDown); frame = requestAnimationFrame(render); return () => { if (frame) cancelAnimationFrame(frame); window.clearTimeout(firstStrike); window.clearInterval(strikeTimer); resizeObserver.disconnect(); intersection.disconnect(); window.removeEventListener("pointermove", pointer); window.removeEventListener("pointerdown", pointerDown); gl.deleteBuffer(buffer); gl.deleteShader(vertex); gl.deleteShader(fragment); gl.deleteProgram(program); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [prefersReducedMotion]); return ( ); } ``` --- # Confluence Lines > A WebGL shader of three light streams meeting on the page background, inked in the theme primary, foreground and muted tones. Install: `npx @modus-ui/cli add confluence-lines` A WebGL shader of three light streams meeting on the page background, inked in the theme primary, foreground and muted tones. It fills its positioned parent, follows the active theme and palette in light and dark mode, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add confluence-lines ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/confluence-lines.tsx`. 3. Paste the code. ## Usage ```tsx import { ConfluenceLines } from "@/components/ui/confluence-lines"; export default function Example() { return (

Content over the background

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :--------- | :--------------------------------------------- | :-------------------------------- | | `speed` | `number` | `1` | Animation speed multiplier. | | `fidelity` | `number` | `0.5` | Spread between the three streams. | | `scale` | `number` | `1` | Scale of the rendered canvas. | | `brightness` | `number` | `1` | CSS brightness filter. | | `opacity` | `number` | `1` | Canvas opacity. | | `hue` | `number` | `0` | Hue rotation in degrees. | | `saturation` | `number` | `1` | CSS saturation filter. | | `backgroundColor` | `string` | theme background | Background behind the canvas. | | `colors` | `string[]` | theme primary, foreground and muted foreground | One colour per stream. | ## Source `components/ui/confluence-lines.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Stream Convergence (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; interface ConfluenceLinesProps { speed?: number; fidelity?: number; scale?: number; brightness?: number; opacity?: number; hue?: number; saturation?: number; /** Defaults to the theme background. */ backgroundColor?: string; /** One CSS colour per stream. Defaults to the theme primary, foreground and muted foreground. */ colors?: string[]; className?: string; } type Rgb = [number, number, number]; const TOKENS = [ "background", "foreground", "primary", "muted-foreground", "border", "card", ] as const; type Theme = Record<(typeof TOKENS)[number], Rgb>; /** Any CSS colour as 0–1 sRGB channels, resolved by the browser. */ function toRgb(color: string): Rgb { const probe = document.createElement("i"); probe.style.display = "none"; probe.style.color = color; document.body.appendChild(probe); const [r = 0, g = 0, b = 0] = (getComputedStyle(probe).color.match(/[\d.]+/g) ?? []).map(Number); probe.remove(); return [r / 255, g / 255, b / 255]; } /** The Modus theme tokens, re-read whenever the mode or palette on changes. */ function useTheme() { const [theme, setTheme] = useState(null); useEffect(() => { const root = document.documentElement; const read = () => { const style = getComputedStyle(root); setTheme( Object.fromEntries( TOKENS.map((t) => [t, toRgb(`hsl(${style.getPropertyValue(`--${t}`).trim()})`)]), ) as Theme, ); }; read(); const observer = new MutationObserver(read); observer.observe(root, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return theme; } const mix = (a: Rgb, b: Rgb, t: number): Rgb => [ a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, ]; // Per-band ink strength: the middle stream is the boldest. const BAND_GAIN = [0.75, 1, 0.55]; const VERTEX_SHADER = ` attribute vec2 position; varying vec2 vUv; void main() { vUv = position * 0.5 + 0.5; gl_Position = vec4(position, 0.0, 1.0); } `; const FRAGMENT_SHADER = /* glsl */ ` precision highp float; uniform float u_time; uniform vec2 u_resolution; uniform float u_interactive_fidelity; uniform vec3 u_band0; uniform vec3 u_band1; uniform vec3 u_band2; uniform vec3 u_background; varying vec2 vUv; mat2 rotate2d(float _angle){ return mat2(cos(_angle),-sin(_angle), sin(_angle),cos(_angle)); } void main() { vec2 p = vUv * 2.0 - 1.0; p.x *= u_resolution.x / u_resolution.y; p = rotate2d(-0.32) * p; vec3 color = u_background; float spread = 0.09 * (0.3 + u_interactive_fidelity * 0.7); float fade = exp(-length(vUv * 2.0 - 1.0) * 0.9); // Streams are inked onto the paper one over the other, so they hold on light and dark grounds. for(int i = 0; i < 3; i++) { float offset = float(1 - i) * spread; float y = p.y + offset + (sin(p.x * 1.8 - u_time * 1.2) * 0.18); float line = sin(y * 4.5 + u_time * 1.6) * 0.5 + 0.5; float wave = smoothstep(0.9, 0.995, line) + smoothstep(0.7, 0.995, line) * 0.18; vec3 ink = i == 0 ? u_band0 : (i == 1 ? u_band1 : u_band2); float gain = i == 0 ? ${BAND_GAIN[0].toFixed(2)} : (i == 1 ? ${BAND_GAIN[1].toFixed(2)} : ${BAND_GAIN[2].toFixed(2)}); color = mix(color, ink, clamp(wave * gain * fade, 0.0, 1.0)); } gl_FragColor = vec4(color, 1.0); } `; function compile(gl: WebGLRenderingContext, type: number, source: string) { const shader = gl.createShader(type); if (!shader) throw new Error("Unable to create Confluence Lines shader"); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error(gl.getShaderInfoLog(shader) ?? "Confluence Lines shader compilation failed"); } return shader; } export function ConfluenceLines({ speed = 1, fidelity = 0.5, scale = 1, brightness = 1, opacity = 1, hue = 0, saturation = 1, backgroundColor, colors, className, }: ConfluenceLinesProps) { const theme = useTheme(); const hostRef = useRef(null); const canvasRef = useRef(null); const prefersReducedMotion = useReducedMotion(); const optionsRef = useRef({ speed, fidelity, inks: [] as Rgb[], ground: [0, 0, 0] as Rgb }); const colorKey = colors?.join(","); useEffect(() => { if (!theme) return; optionsRef.current.ground = backgroundColor ? toRgb(backgroundColor) : theme.background; optionsRef.current.inks = colorKey ? colorKey.split(",").map(toRgb) : [theme.primary, theme.foreground, theme["muted-foreground"]]; }, [theme, backgroundColor, colorKey]); optionsRef.current.speed = speed; optionsRef.current.fidelity = fidelity; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; if (!host || !canvas || !theme) return; const gl = canvas.getContext("webgl", { alpha: true, antialias: false }); if (!gl) return; const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER); const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); const program = gl.createProgram(); if (!program) return; gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error(gl.getProgramInfoLog(program) ?? "Confluence Lines program link failed"); } gl.useProgram(program); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW, ); const position = gl.getAttribLocation(program, "position"); gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); const uTime = gl.getUniformLocation(program, "u_time"); const uResolution = gl.getUniformLocation(program, "u_resolution"); const uFidelity = gl.getUniformLocation(program, "u_interactive_fidelity"); const uBands = ["u_band0", "u_band1", "u_band2"].map((name) => gl.getUniformLocation(program, name), ); const uBackground = gl.getUniformLocation(program, "u_background"); let frame = 0; let visible = true; const draw = (now: number) => { const options = optionsRef.current; gl.uniform1f(uTime, now * 0.0003 * options.speed); gl.uniform1f(uFidelity, options.fidelity); uBands.forEach((location, index) => { gl.uniform3fv(location, options.inks[index] ?? options.inks[0] ?? options.ground); }); gl.uniform3fv(uBackground, options.ground); gl.drawArrays(gl.TRIANGLES, 0, 6); }; const render = (now: number) => { draw(now); frame = visible && !document.hidden ? requestAnimationFrame(render) : 0; }; const resize = () => { const bounds = host.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.max(1, Math.round(bounds.width * dpr)); canvas.height = Math.max(1, Math.round(bounds.height * dpr)); gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform2f(uResolution, canvas.width, canvas.height); if (prefersReducedMotion) draw(8000); }; const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host); resize(); let intersection: IntersectionObserver | undefined; if (!prefersReducedMotion) { intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible && !frame) frame = requestAnimationFrame(render); if (!visible && frame) { cancelAnimationFrame(frame); frame = 0; } }); intersection.observe(host); frame = requestAnimationFrame(render); } return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); intersection?.disconnect(); gl.deleteBuffer(buffer); gl.deleteShader(vertex); gl.deleteShader(fragment); gl.deleteProgram(program); }; }, [prefersReducedMotion, theme]); return ( ); } ``` --- # Silk Bands > A WebGL background of slow, wide bands printed through a coarse halftone screen in a ramp of the theme colours. Install: `npx @modus-ui/cli add silk-bands` A full-bleed WebGL shader of three slow, wide bands printed through a coarse scanning halftone screen. The colours are a ramp built from the theme (`--primary` at several lightnesses, `--foreground` and `--muted-foreground`) over `--background`, so it is ink on paper in light mode and follows palette changes live. The bands gather on the left and fade out to the right, leaving room for text, and drift with the pointer. It fills its positioned parent, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add silk-bands ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/silk-bands.tsx`. 3. Paste the code. ## Usage ```tsx import { SilkBands } from "@/components/ui/silk-bands"; export default function Example() { return (

Content over the background

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :--------- | :------------------- | :------------------------------------------------------------------ | | `speed` | `number` | `1` | Animation speed multiplier. | | `pointerAmount` | `number` | `1` | How strongly the bands follow the pointer. | | `smoothing` | `number` | `0.035` | Pointer easing per frame. | | `brightness` | `number` | `1` | CSS brightness filter. | | `opacity` | `number` | `1` | Canvas opacity. | | `hue` | `number` | `0` | Hue rotation in degrees. | | `saturation` | `number` | `1` | CSS saturation filter. | | `backgroundColor` | `string` | theme `--background` | Base colour behind the bands. | | `colors` | `string[]` | theme ramp | Six band colours (any CSS colour): three bands, two accents, bloom. | ## Source `components/ui/silk-bands.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Ribbon Field (MIT, © 2026 Meng To). import { useEffect, useRef } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface SilkBandsProps { speed?: number; pointerAmount?: number; smoothing?: number; brightness?: number; opacity?: number; hue?: number; saturation?: number; backgroundColor?: string; colors?: string[]; className?: string; } const readToken = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); /** A theme token with its lightness shifted, for building a ramp from one hue. */ function shade(name: string, shift: number) { const [h = "0", sat = "0%", l = "50%"] = readToken(name).split(/\s+/); return `hsl(${h} ${sat} ${Math.min(96, Math.max(4, Number.parseFloat(l) + shift))}%)`; } /** The six band colours, ramped from the theme's primary, foreground and muted ink. */ const themeColors = () => [ shade("--primary", 0), shade("--foreground", 0), shade("--muted-foreground", 0), shade("--primary", 18), shade("--primary", -12), shade("--primary", 30), ]; const COLOR_UNIFORMS = ["teal", "cyan", "indigo", "purple", "blue", "bloomColor"]; const VERTEX_SHADER = ` attribute vec2 position; void main() { gl_Position = vec4(position, 0.0, 1.0); } `; const FRAGMENT_SHADER = ` precision highp float; uniform vec2 resolution; uniform float time; uniform vec2 pointer; uniform vec3 teal; uniform vec3 cyan; uniform vec3 indigo; uniform vec3 purple; uniform vec3 blue; uniform vec3 bloomColor; uniform vec3 base; float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } float ribbon(vec2 uv, float offset, float width, float phase) { float y = 0.48 + 0.16 * sin((uv.x * 1.6) + phase) + 0.045 * sin((uv.x * 7.0) - phase * 0.7); float d = abs(uv.y - y - offset); return exp(-(d * d) / width); } void main() { vec2 uv = gl_FragCoord.xy / resolution.xy; vec2 p = uv; p.x *= resolution.x / resolution.y; float t = time * 0.22; float drift = (pointer.x - 0.5) * 0.06; float rightFade = 1.0 - smoothstep(0.30, 0.78, uv.x); float centerDark = 1.0 - smoothstep(0.0, 0.88, distance(uv, vec2(0.86, 0.52))); float r1 = ribbon(vec2(uv.x + drift, uv.y), 0.03, 0.0065, t + 0.9); float r2 = ribbon(vec2(uv.x - drift * 0.7, uv.y), -0.23, 0.0085, t + 3.25); float r3 = ribbon(vec2(uv.x + drift * 0.4, uv.y), 0.25, 0.014, t + 1.85); float glow = r1 * 1.14 + r2 * 1.05 + r3 * 0.48; vec3 col = vec3(0.0); col += cyan * r1 * 0.92; col += teal * r1 * 0.62; col += indigo * r3 * 0.42; col += blue * r2 * 0.66; col += purple * (r2 + r3) * 0.30; float bloom = exp(-pow(distance(uv, vec2(0.26, 0.58 + 0.035 * sin(t))), 2.0) / 0.050); bloom += exp(-pow(distance(uv, vec2(0.32, 0.24 + 0.025 * cos(t))), 2.0) / 0.030); col += bloomColor * bloom * 0.34; vec2 grid = fract(gl_FragCoord.xy / 10.0) - 0.5; float dotShape = smoothstep(0.29, 0.11, length(grid)); float noise = hash(floor(gl_FragCoord.xy / 10.0)); float scan = 0.72 + 0.28 * sin((uv.x + uv.y) * 38.0 + time * 1.3); float dots = dotShape * (0.48 + 0.52 * noise) * scan; float micro = hash(gl_FragCoord.xy + time) * 0.035; float alpha = clamp((glow * 1.55 + bloom * 0.50) * dots * rightFade, 0.0, 1.0); alpha *= 1.0 - centerDark * 0.56; vec3 finalColor = mix(base, col, clamp(alpha * 1.55, 0.0, 1.0)); finalColor += micro * rightFade; gl_FragColor = vec4(finalColor, 1.0); } `; function compile(gl: WebGLRenderingContext, type: number, source: string) { const shader = gl.createShader(type); if (!shader) throw new Error("Unable to create Silk Bands shader"); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error(gl.getShaderInfoLog(shader) ?? "Silk Bands shader compilation failed"); } return shader; } let probe: CanvasRenderingContext2D | null = null; /** Resolves any CSS colour string to 0–1 RGB through a 1×1 canvas. */ function toRgb(color: string): [number, number, number] { probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!probe) return [0, 0, 0]; probe.clearRect(0, 0, 1, 1); probe.fillStyle = color; probe.fillRect(0, 0, 1, 1); const [r, g, b] = probe.getImageData(0, 0, 1, 1).data; return [r / 255, g / 255, b / 255]; } export function SilkBands({ speed = 1, pointerAmount = 1, smoothing = 0.035, brightness = 1, opacity = 1, hue = 0, saturation = 1, backgroundColor, colors, className, }: SilkBandsProps) { const hostRef = useRef(null); const canvasRef = useRef(null); const prefersReducedMotion = useReducedMotion(); const optionsRef = useRef({ speed, pointerAmount, smoothing, backgroundColor, colors }); optionsRef.current = { speed, pointerAmount, smoothing, backgroundColor, colors }; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; if (!host || !canvas) return; const gl = canvas.getContext("webgl", { alpha: true, antialias: false, premultipliedAlpha: false, }); if (!gl) return; const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER); const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); const program = gl.createProgram(); if (!program) return; gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error(gl.getProgramInfoLog(program) ?? "Silk Bands program link failed"); } gl.useProgram(program); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW, ); const position = gl.getAttribLocation(program, "position"); gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); const resolution = gl.getUniformLocation(program, "resolution"); const time = gl.getUniformLocation(program, "time"); const pointerUniform = gl.getUniformLocation(program, "pointer"); const baseUniform = gl.getUniformLocation(program, "base"); const colorUniforms = COLOR_UNIFORMS.map((name) => gl.getUniformLocation(program, name)); let base: [number, number, number] = [0, 0, 0]; let palette: [number, number, number][] = []; const readTheme = () => { const options = optionsRef.current; base = toRgb(options.backgroundColor ?? `hsl(${readToken("--background")})`); const fallback = themeColors(); palette = fallback.map((color, index) => toRgb(options.colors?.[index] ?? color)); }; readTheme(); let mouseX = 0.72; let mouseY = 0.42; let targetX = 0.72; let targetY = 0.42; let frame = 0; let visible = true; const startedAt = performance.now(); const draw = (elapsed: number) => { const options = optionsRef.current; mouseX += (targetX - mouseX) * options.smoothing; mouseY += (targetY - mouseY) * options.smoothing; gl.uniform1f(time, elapsed * options.speed); gl.uniform2f(pointerUniform, mouseX, mouseY); gl.uniform3f(baseUniform, ...base); colorUniforms.forEach((location, index) => gl.uniform3f(location, ...palette[index])); gl.drawArrays(gl.TRIANGLES, 0, 6); }; const render = (now: number) => { draw((now - startedAt) * 0.001); frame = visible && !document.hidden ? requestAnimationFrame(render) : 0; }; const pointer = (event: PointerEvent) => { const bounds = host.getBoundingClientRect(); if ( event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom ) { return; } const amount = optionsRef.current.pointerAmount; targetX = 0.72 + ((event.clientX - bounds.left) / Math.max(bounds.width, 1) - 0.72) * amount; targetY = 0.42 + (1 - (event.clientY - bounds.top) / Math.max(bounds.height, 1) - 0.42) * amount; }; const resize = () => { const bounds = host.getBoundingClientRect(); const ratio = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.max(1, Math.floor(bounds.width * ratio)); canvas.height = Math.max(1, Math.floor(bounds.height * ratio)); gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform2f(resolution, canvas.width, canvas.height); if (prefersReducedMotion) draw(4); }; const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host); resize(); const themeObserver = new MutationObserver(() => { readTheme(); if (prefersReducedMotion) draw(4); }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme", "data-palette"], }); let intersection: IntersectionObserver | undefined; if (!prefersReducedMotion) { intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible && !frame) frame = requestAnimationFrame(render); if (!visible && frame) { cancelAnimationFrame(frame); frame = 0; } }); intersection.observe(host); window.addEventListener("pointermove", pointer, { passive: true }); frame = requestAnimationFrame(render); } return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); themeObserver.disconnect(); intersection?.disconnect(); window.removeEventListener("pointermove", pointer); gl.deleteBuffer(buffer); gl.deleteShader(vertex); gl.deleteShader(fragment); gl.deleteProgram(program); }; }, [prefersReducedMotion, backgroundColor, colors]); return ( ); } ``` --- # Mercury Bloom > A raymarched WebGL blob of liquid metal that reflects your theme and turns toward the pointer. Install: `npx @modus-ui/cli add mercury-bloom` A full-bleed raymarched WebGL shader of a metal blob that morphs with layered simplex noise. Its studio environment is built from the theme's background, card and primary tones, so it reads as polished pewter on paper in light mode and as dark mercury in dark mode, and follows the palette. It fills its positioned parent, re-reads the theme when the mode or palette changes, pauses when off screen and stays still when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add mercury-bloom ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/mercury-bloom.tsx`. 3. Paste the code. ## Usage ```tsx import { MercuryBloom } from "@/components/ui/mercury-bloom"; export default function Example() { return (
); } ``` ## Props | Prop | Type | Default | Description | | :------------ | :------- | :------ | :---------------------------------------------- | | `speed` | `number` | `1` | Animation speed multiplier. | | `morph` | `number` | `1.8` | Strength of the surface deformation. | | `noiseScale` | `number` | `1.3` | Frequency of the deformation noise. | | `mouseAmount` | `number` | `0.15` | How far the camera looks toward the pointer. | | `metal` | `number` | `1` | Reflection and specular strength. | | `camera` | `number` | `6.5` | Camera distance. | | `tintHue` | `number` | `220` | Hue of the optional tint in degrees. | | `tintAmount` | `number` | `0` | Strength of the tint (0 keeps the theme metal). | ## Source `components/ui/mercury-bloom.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Liquid Form (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface MercuryBloomProps { speed?: number; morph?: number; noiseScale?: number; mouseAmount?: number; metal?: number; camera?: number; tintHue?: number; tintAmount?: number; className?: string; } type Hsl = [number, number, number]; /** Theme tokens are bare HSL channels ("24 8% 20%"). */ function readTheme() { const style = getComputedStyle(document.documentElement); const read = (name: string): Hsl => { const [h = 0, s = 0, l = 0] = style .getPropertyValue(name) .trim() .split(/[\s,/%]+/) .map(Number); return [h, s, l]; }; const background = read("--background"); return { dark: background[2] < 50, background, foreground: read("--foreground"), primary: read("--primary"), muted: read("--muted-foreground"), border: read("--border"), }; } /** Bumps on light/dark or palette changes so colours re-resolve. */ function useThemeVersion() { const [version, setVersion] = useState(0); useEffect(() => { const observer = new MutationObserver(() => setVersion((v) => v + 1)); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return version; } /** HSL channels to sRGB 0–1 for shader uniforms. */ function toRgb([h, s, l]: Hsl): [number, number, number] { const sat = Math.min(100, Math.max(0, s)) / 100; const lig = Math.min(100, Math.max(0, l)) / 100; const k = (n: number) => (n + h / 30) % 12; const a = sat * Math.min(lig, 1 - lig); const f = (n: number) => lig - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); return [f(0), f(8), f(4)]; } const VERTEX_SHADER = ` attribute vec2 a_pos; void main(){ gl_Position = vec4(a_pos, 0.0, 1.0); } `; const FRAGMENT_SHADER = ` precision highp float; uniform vec2 u_res; uniform float u_time; uniform vec2 u_mouse; uniform float u_morph; uniform float u_noise_scale; uniform float u_mouse_amount; uniform float u_metal; uniform float u_camera; uniform vec3 u_bg; uniform vec3 u_ink; uniform vec3 u_hi; #define MAX_STEPS 70 #define MAX_DIST 20.0 #define SURF_DIST 0.002 vec3 mod289(vec3 x){return x-floor(x*(1.0/289.0))*289.0;} vec4 mod289(vec4 x){return x-floor(x*(1.0/289.0))*289.0;} vec4 permute(vec4 x){return mod289(((x*34.0)+1.0)*x);} vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;} float snoise(vec3 v){ const vec2 C=vec2(1.0/6.0,1.0/3.0); const vec4 D=vec4(0.0,0.5,1.0,2.0); vec3 i=floor(v+dot(v,C.yyy)); vec3 x0=v-i+dot(i,C.xxx); vec3 g=step(x0.yzx,x0.xyz); vec3 l=1.0-g; vec3 i1=min(g.xyz,l.zxy); vec3 i2=max(g.xyz,l.zxy); vec3 x1=x0-i1+C.xxx; vec3 x2=x0-i2+C.yyy; vec3 x3=x0-D.yyy; i=mod289(i); vec4 p=permute(permute(permute(i.z+vec4(0.0,i1.z,i2.z,1.0))+i.y+vec4(0.0,i1.y,i2.y,1.0))+i.x+vec4(0.0,i1.x,i2.x,1.0)); float n_=0.142857142857; vec3 ns=n_*D.wyz-D.xzx; vec4 j=p-49.0*floor(p*ns.z*ns.z); vec4 x_=floor(j*ns.z); vec4 y_=floor(j-7.0*x_); vec4 x=x_*ns.x+ns.yyyy; vec4 y=y_*ns.x+ns.yyyy; vec4 h=1.0-abs(x)-abs(y); vec4 b0=vec4(x.xy,y.xy); vec4 b1=vec4(x.zw,y.zw); vec4 s0=floor(b0)*2.0+1.0; vec4 s1=floor(b1)*2.0+1.0; vec4 sh=-step(h,vec4(0.0)); vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy; vec4 a1=b1.xzyw+s1.xzyw*sh.zzww; vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y); vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w); vec4 norm=taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3))); p0*=norm.x;p1*=norm.y;p2*=norm.z;p3*=norm.w; vec4 m=max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.0); m=m*m; return 42.0*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3))); } float map(vec3 p, float t) { float radius = 1.8; float morph = snoise(p * (0.8 * u_noise_scale) + t * 0.1) * 0.2; morph += snoise(p * (1.5 * u_noise_scale) - t * 0.05 + 10.0) * 0.08; morph += snoise(p * (3.0 * u_noise_scale) + t * 0.02) * 0.02; return length(p) - radius + morph * u_morph; } vec3 calcNormal(vec3 p, float t) { vec2 e = vec2(0.002, 0.0); return normalize(vec3( map(p+e.xyy, t) - map(p-e.xyy, t), map(p+e.yxy, t) - map(p-e.yxy, t), map(p+e.yyx, t) - map(p-e.yyx, t) )); } vec3 envLighting(vec3 rd, vec2 mouse) { vec3 col = u_bg * 0.35; vec3 keyDir = normalize(vec3(0.5 + mouse.x, 1.0 + mouse.y * 0.5, 1.2)); float key = pow(max(dot(rd, keyDir), 0.0), 12.0); col += u_hi * key * 1.5; vec3 rimDir = normalize(vec3(-0.8, -0.2, -1.0)); float rim = pow(max(dot(rd, rimDir), 0.0), 6.0); col += u_ink * rim * 1.4; vec3 fillDir = normalize(vec3(-1.0, 0.5, 0.5)); float fill = pow(max(dot(rd, fillDir), 0.0), 3.0); col += mix(u_ink, u_bg, 0.5) * fill * 0.7; float panel = exp(-pow((rd.y - 0.2) * 4.0, 2.0)) * smoothstep(-0.5, 0.5, rd.z); col += u_bg * 0.25 * panel; return col; } void main() { vec2 uv = (gl_FragCoord.xy - u_res * 0.5) / min(u_res.x, u_res.y); float t = u_time * 0.8; vec2 m = u_mouse * u_mouse_amount; vec3 ro = vec3(0.0, 0.0, u_camera); vec3 lookAt = vec3(m.x, m.y, 0.0); vec3 fwd = normalize(lookAt - ro); vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), fwd)); vec3 up = cross(fwd, right); vec3 rd = normalize(fwd + uv.x * right + uv.y * up); vec3 col = vec3(0.0); bool hit = false; float d = 0.0; for(int i=0; i MAX_DIST || abs(ds) < SURF_DIST) break; } if(d < MAX_DIST) { hit = true; vec3 p = ro + rd * d; vec3 n = calcNormal(p, t); vec3 ref = reflect(rd, n); float fresnel = pow(1.0 - max(dot(n, -rd), 0.0), 4.0); fresnel = mix(0.4, 1.0, fresnel); vec3 env = envLighting(ref, u_mouse); col = env * fresnel * 1.8 * u_metal; vec3 lightPos = normalize(vec3(0.5 + u_mouse.x, 1.0, 1.0)); float spec = pow(max(dot(ref, lightPos), 0.0), 60.0); col += vec3(1.0) * spec * 2.0 * u_metal; float disp = map(p, t) - (length(p) - 1.8); col *= mix(0.7, 1.0, smoothstep(-0.1, 0.1, disp)); } col = col / (col + 0.5); col = pow(col, vec3(1.0/2.2)); // The ground stays the page colour, with a soft halo of the ink around the form. float halo = exp(-length(uv) * 2.2); vec3 ground = mix(u_bg, u_ink, halo * 0.18); if (!hit) col = ground; gl_FragColor = vec4(col, 1.0); } `; function compile(gl: WebGLRenderingContext, type: number, source: string) { const shader = gl.createShader(type); if (!shader) throw new Error("Unable to create Mercury Bloom shader"); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error(gl.getShaderInfoLog(shader) ?? "Mercury Bloom shader compilation failed"); } return shader; } export function MercuryBloom({ speed = 1, morph = 1.8, noiseScale = 1.3, mouseAmount = 0.15, metal = 1, camera = 6.5, tintHue = 220, tintAmount = 0, className, }: MercuryBloomProps) { const themeVersion = useThemeVersion(); const hostRef = useRef(null); const canvasRef = useRef(null); const prefersReducedMotion = useReducedMotion(); const optionsRef = useRef({ speed, morph, noiseScale, mouseAmount, metal, camera }); optionsRef.current = { speed, morph, noiseScale, mouseAmount, metal, camera }; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; if (!host || !canvas) return; const gl = canvas.getContext("webgl", { alpha: false, antialias: false, powerPreference: "high-performance", }); if (!gl) return; const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER); const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); const program = gl.createProgram(); if (!program) return; gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error(gl.getProgramInfoLog(program) ?? "Mercury Bloom program link failed"); } gl.useProgram(program); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW); const position = gl.getAttribLocation(program, "a_pos"); gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); const uniforms = { resolution: gl.getUniformLocation(program, "u_res"), time: gl.getUniformLocation(program, "u_time"), mouse: gl.getUniformLocation(program, "u_mouse"), morph: gl.getUniformLocation(program, "u_morph"), noiseScale: gl.getUniformLocation(program, "u_noise_scale"), mouseAmount: gl.getUniformLocation(program, "u_mouse_amount"), metal: gl.getUniformLocation(program, "u_metal"), camera: gl.getUniformLocation(program, "u_camera"), }; const theme = readTheme(); const [ph, ps, pl] = theme.primary; gl.uniform3fv(gl.getUniformLocation(program, "u_bg"), toRgb(theme.background)); gl.uniform3fv( gl.getUniformLocation(program, "u_ink"), toRgb([ph, Math.max(ps, 40), theme.dark ? Math.max(pl, 55) : Math.min(Math.max(pl, 30), 45)]), ); gl.uniform3fv(gl.getUniformLocation(program, "u_hi"), toRgb([ph + 25, 30, 94])); let targetX = 0; let targetY = 0; let mouseX = 0; let mouseY = 0; let frame = 0; let visible = true; const startedAt = performance.now(); const draw = (elapsed: number) => { const options = optionsRef.current; mouseX += (targetX - mouseX) * 0.05; mouseY += (targetY - mouseY) * 0.05; gl.uniform2f(uniforms.resolution, canvas.width, canvas.height); gl.uniform1f(uniforms.time, elapsed * options.speed); gl.uniform2f(uniforms.mouse, mouseX, mouseY); gl.uniform1f(uniforms.morph, options.morph); gl.uniform1f(uniforms.noiseScale, options.noiseScale); gl.uniform1f(uniforms.mouseAmount, options.mouseAmount); gl.uniform1f(uniforms.metal, options.metal); gl.uniform1f(uniforms.camera, options.camera); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); }; const render = (now: number) => { draw((now - startedAt) * 0.001); frame = visible && !document.hidden ? requestAnimationFrame(render) : 0; }; const resize = () => { const bounds = host.getBoundingClientRect(); const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5); canvas.width = Math.max(1, Math.round(bounds.width * pixelRatio)); canvas.height = Math.max(1, Math.round(bounds.height * pixelRatio)); gl.viewport(0, 0, canvas.width, canvas.height); if (prefersReducedMotion) draw(6); }; const pointer = (event: PointerEvent) => { const bounds = canvas.getBoundingClientRect(); if ( event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom ) { return; } targetX = ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1; targetY = -(((event.clientY - bounds.top) / Math.max(1, bounds.height)) * 2 - 1); }; const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host); resize(); let intersection: IntersectionObserver | undefined; if (!prefersReducedMotion) { intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible && !frame) frame = requestAnimationFrame(render); if (!visible && frame) { cancelAnimationFrame(frame); frame = 0; } }); intersection.observe(host); window.addEventListener("pointermove", pointer, { passive: true }); frame = requestAnimationFrame(render); } return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); intersection?.disconnect(); window.removeEventListener("pointermove", pointer); gl.deleteBuffer(buffer); gl.deleteShader(vertex); gl.deleteShader(fragment); gl.deleteProgram(program); }; }, [prefersReducedMotion, themeVersion]); const tintFilter = tintAmount > 0 ? `sepia(${tintAmount}) saturate(${1 + tintAmount * 5}) hue-rotate(${tintHue - 35}deg)` : undefined; return ( ); } ``` --- # Lumen Core > A slowly turning sphere of smoke-veined light in a field of drifting motes, shaded from your theme. Install: `npx @modus-ui/cli add lumen-core` A WebGL sphere set right of centre, veined with rotating fbm smoke and ringed by a soft halo, over a canvas of drifting motes. Its shading is a lightness ramp on the theme's primary hue: a glowing core on a dark background, an inked one on paper, following the palette picker. It fills its positioned parent, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add lumen-core ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/lumen-core.tsx`. 3. Paste the code. ## Usage ```tsx import { LumenCore } from "@/components/ui/lumen-core"; export default function Example() { return (

Content over the background

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :------- | :------------------------- | :---------------------------------------- | | `speed` | `number` | `1` | Rotation speed multiplier. | | `scale` | `number` | `1` | Scale of the core canvas. | | `smokeScale` | `number` | `1` | Size of the smoke detail. | | `smokeStrength` | `number` | `1` | Brightness of the smoke veil. | | `smokeSpeed` | `number` | `1` | Speed of the smoke flow. | | `hue` | `number` | `0` | Hue rotation in degrees (core and motes). | | `saturation` | `number` | `1` | Orb colour saturation. | | `glow` | `number` | `1` | Strength of the rim and outer glow. | | `starDensity` | `number` | `1` | Star count multiplier. | | `starSpeed` | `number` | `1` | Star drift speed. | | `starSize` | `number` | `1` | Star size multiplier. | | `brightness` | `number` | `1` | CSS brightness filter on the core. | | `opacity` | `number` | `1` | Orb opacity. | | `backgroundColor` | `string` | `"hsl(var(--background))"` | Colour behind the core. | ## Source `components/ui/lumen-core.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Energy Orb (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; interface LumenCoreProps { speed?: number; scale?: number; smokeScale?: number; smokeStrength?: number; smokeSpeed?: number; hue?: number; saturation?: number; glow?: number; starDensity?: number; starSpeed?: number; starSize?: number; brightness?: number; opacity?: number; /** Colour behind the core; defaults to the theme background. */ backgroundColor?: string; className?: string; } type Hsl = [number, number, number]; /** Theme tokens are bare HSL channels ("24 8% 20%"). */ function readTheme() { const style = getComputedStyle(document.documentElement); const read = (name: string): Hsl => { const [h = 0, s = 0, l = 0] = style .getPropertyValue(name) .trim() .split(/[\s,/%]+/) .map(Number); return [h, s, l]; }; const background = read("--background"); return { dark: background[2] < 50, background, foreground: read("--foreground"), primary: read("--primary"), muted: read("--muted-foreground"), }; } /** Comma-separated hsl(), which both canvas and THREE.Color parse. */ const hsl = ([h, s, l]: Hsl, alpha = 1) => `hsla(${Math.round(h)}, ${Math.round(Math.min(100, Math.max(0, s)))}%, ${Math.round(Math.min(100, Math.max(0, l)))}%, ${alpha})`; /** HSL channels to linear-free 0..1 RGB for shader uniforms. */ function rgb([h, s, l]: Hsl) { const sat = Math.min(100, Math.max(0, s)) / 100; const light = Math.min(100, Math.max(0, l)) / 100; const k = (n: number) => (n + h / 30) % 12; const a = sat * Math.min(light, 1 - light); const f = (n: number) => light - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); return [f(0), f(8), f(4)]; } /** Bumps on light/dark or palette changes so canvas colours re-resolve. */ function useThemeVersion() { const [version, setVersion] = useState(0); useEffect(() => { const observer = new MutationObserver(() => setVersion((v) => v + 1)); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return version; } type Star = { x: number; y: number; depth: number; phase: number; drift: number; size: number }; const VERTEX_SHADER = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}"; const FRAGMENT_SHADER = [ "precision highp float;", "uniform float uT;uniform vec2 uR;", "uniform float uSmokeScale;uniform float uSmokeStrength;uniform float uSmokeSpeed;", "uniform float uHue;uniform float uSaturation;uniform float uGlow;", "uniform vec3 uDeep;uniform vec3 uMid;uniform vec3 uBright;uniform vec3 uRim;", "float hash(vec3 p){p=fract(p*0.3183099+vec3(0.1,0.2,0.3));p*=17.0;return fract(p.x*p.y*p.z*(p.x+p.y+p.z));}", "float noise(vec3 x){vec3 i=floor(x);vec3 f=fract(x);f=f*f*(3.0-2.0*f);", " return mix(mix(mix(hash(i),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y),", " mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z);}", "float fbm(vec3 p){float v=0.0;float a=0.5;for(int i=0;i<5;i++){v+=a*noise(p);p=p*2.03+vec3(1.7);a*=0.5;}return v;}", "vec3 gradeColor(vec3 color){", " float luminance=dot(color,vec3(0.2126,0.7152,0.0722));", " color=mix(vec3(luminance),color,uSaturation);", " vec3 axis=normalize(vec3(1.0));", " return max(vec3(0.0),color*cos(uHue)+cross(axis,color)*sin(uHue)+axis*dot(axis,color)*(1.0-cos(uHue)));", "}", "void main(){", " vec2 uv=(gl_FragCoord.xy-vec2(0.6,0.46)*uR)/min(uR.x,uR.y);", " float r=length(uv);", " float R=0.24;", " vec3 col=vec3(0.0);float alpha=0.0;", " if(r=R){", " glow=clamp(glow,0.0,1.0);", " col=uRim;", " alpha=clamp(glow*0.7*uGlow,0.0,1.0);", " } else {", " float rim=smoothstep(R-0.03,R,r);", " col=mix(col,uRim,clamp(rim*0.6*uGlow,0.0,1.0));", " }", " col=gradeColor(col);", " gl_FragColor=vec4(col,alpha);", "}", ].join("\n"); function compile(gl: WebGLRenderingContext, type: number, source: string) { const shader = gl.createShader(type); if (!shader) throw new Error("Unable to create Lumen Core shader"); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error(gl.getShaderInfoLog(shader) ?? "Lumen Core shader compilation failed"); } return shader; } function seeded(index: number, salt: number) { return Math.abs(Math.sin(index * 91.173 + salt * 17.719) * 43758.5453) % 1; } function createStars(count: number): Star[] { return Array.from({ length: count }, (_, index) => ({ x: seeded(index, 1), y: seeded(index, 2), depth: 0.25 + seeded(index, 3) * 0.75, phase: seeded(index, 4) * Math.PI * 2, drift: 0.35 + seeded(index, 5) * 0.65, size: 0.45 + seeded(index, 6) * 1.15, })); } function fract(value: number) { return value - Math.floor(value); } export function LumenCore({ speed = 1, scale = 1, smokeScale = 1, smokeStrength = 1, smokeSpeed = 1, hue = 0, saturation = 1, glow = 1, starDensity = 1, starSpeed = 1, starSize = 1, brightness = 1, opacity = 1, backgroundColor = "hsl(var(--background))", className, }: LumenCoreProps) { const hostRef = useRef(null); const starCanvasRef = useRef(null); const canvasRef = useRef(null); const prefersReducedMotion = useReducedMotion(); const theme = useThemeVersion(); const optionsRef = useRef({ speed, smokeScale, smokeStrength, smokeSpeed, hue, saturation, glow, starDensity, starSpeed, starSize, }); optionsRef.current = { speed, smokeScale, smokeStrength, smokeSpeed, hue, saturation, glow, starDensity, starSpeed, starSize, }; useEffect(() => { const host = hostRef.current; const starCanvas = starCanvasRef.current; const canvas = canvasRef.current; if (!host || !starCanvas || !canvas) return; // The core is shaded with a lightness ramp on the theme's primary hue. const { dark, primary } = readTheme(); const [h, s] = primary; const sat = Math.max(s, 36); const ramp = dark ? { deep: rgb([h, sat, 7]), mid: rgb([h, sat, 30]), bright: rgb([h - 16, sat, 80]), rim: rgb([h, sat * 0.7, 86]), } : { deep: rgb([h, sat, 12]), mid: rgb([h, sat, 30]), bright: rgb([h - 16, sat, 66]), rim: rgb([h, sat, 46]), }; const starContext = starCanvas.getContext("2d", { alpha: true }); const gl = canvas.getContext("webgl", { alpha: true, premultipliedAlpha: false, antialias: true, }); if (!starContext || !gl) return; const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER); const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); const program = gl.createProgram(); if (!program) { gl.deleteShader(vertex); gl.deleteShader(fragment); return; } gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error(gl.getProgramInfoLog(program) ?? "Lumen Core program link failed"); } gl.useProgram(program); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const position = gl.getAttribLocation(program, "p"); gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); const uniforms = { time: gl.getUniformLocation(program, "uT"), resolution: gl.getUniformLocation(program, "uR"), smokeScale: gl.getUniformLocation(program, "uSmokeScale"), smokeStrength: gl.getUniformLocation(program, "uSmokeStrength"), smokeSpeed: gl.getUniformLocation(program, "uSmokeSpeed"), hue: gl.getUniformLocation(program, "uHue"), saturation: gl.getUniformLocation(program, "uSaturation"), glow: gl.getUniformLocation(program, "uGlow"), deep: gl.getUniformLocation(program, "uDeep"), mid: gl.getUniformLocation(program, "uMid"), bright: gl.getUniformLocation(program, "uBright"), rim: gl.getUniformLocation(program, "uRim"), }; gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.clearColor(0, 0, 0, 0); const stars = createStars(180); let width = 1; let height = 1; let starDpr = 1; let frame = 0; let visible = true; const startedAt = performance.now(); const drawStars = (elapsed: number) => { const options = optionsRef.current; starContext.setTransform(1, 0, 0, 1, 0, 0); starContext.clearRect(0, 0, starCanvas.width, starCanvas.height); const density = Math.max(0, options.starDensity); const count = Math.min(stars.length, Math.round(((width * height) / 4_200) * density)); if (!count) return; starContext.setTransform(starDpr, 0, 0, starDpr, 0, 0); starContext.globalCompositeOperation = dark ? "screen" : "multiply"; const particleTime = prefersReducedMotion ? 0 : elapsed * Math.max(0, options.starSpeed); const colorHue = fract((h + options.hue) / 360) * 360; for (let index = 0; index < count; index += 1) { const star = stars[index]; const x = fract(star.x + particleTime * 0.0022 * star.drift) * width; const y = fract(star.y - particleTime * 0.0008 * star.depth + 1) * height; const twinkle = prefersReducedMotion ? 0.78 : 0.58 + Math.sin(particleTime * (0.8 + star.depth) + star.phase) * 0.24; const alpha = Math.max(0.08, twinkle * (0.22 + star.depth * 0.48)); const radius = Math.max(0.35, star.size * star.depth * Math.max(0.25, options.starSize)); starContext.fillStyle = `hsla(${colorHue}, ${sat}%, ${dark ? 72 + star.depth * 20 : 40 - star.depth * 20}%, ${alpha})`; starContext.beginPath(); starContext.arc(x, y, radius, 0, Math.PI * 2); starContext.fill(); } starContext.globalCompositeOperation = "source-over"; }; const draw = (elapsed: number) => { const options = optionsRef.current; drawStars(elapsed); gl.uniform1f(uniforms.time, elapsed * options.speed); gl.uniform1f(uniforms.smokeScale, Math.max(0.01, options.smokeScale)); gl.uniform1f(uniforms.smokeStrength, Math.max(0, options.smokeStrength)); gl.uniform1f(uniforms.smokeSpeed, Math.max(0, options.smokeSpeed)); gl.uniform1f(uniforms.hue, (options.hue * Math.PI) / 180); gl.uniform1f(uniforms.saturation, Math.max(0, options.saturation)); gl.uniform1f(uniforms.glow, Math.max(0, options.glow)); gl.uniform3fv(uniforms.deep, ramp.deep); gl.uniform3fv(uniforms.mid, ramp.mid); gl.uniform3fv(uniforms.bright, ramp.bright); gl.uniform3fv(uniforms.rim, ramp.rim); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 3); }; const render = (now: number) => { frame = 0; draw((now - startedAt) * 0.001); if (visible && !document.hidden) frame = requestAnimationFrame(render); }; const resize = () => { const bounds = host.getBoundingClientRect(); width = Math.max(1, bounds.width); height = Math.max(1, bounds.height); const dpr = Math.min(window.devicePixelRatio || 1, 2); const bufferWidth = Math.max(1, Math.round(width * dpr)); const bufferHeight = Math.max(1, Math.round(height * dpr)); if (canvas.width !== bufferWidth || canvas.height !== bufferHeight) { canvas.width = bufferWidth; canvas.height = bufferHeight; } gl.viewport(0, 0, bufferWidth, bufferHeight); gl.uniform2f(uniforms.resolution, bufferWidth, bufferHeight); starDpr = Math.min(window.devicePixelRatio || 1, 1.5); const starWidth = Math.max(1, Math.round(width * starDpr)); const starHeight = Math.max(1, Math.round(height * starDpr)); if (starCanvas.width !== starWidth || starCanvas.height !== starHeight) { starCanvas.width = starWidth; starCanvas.height = starHeight; } if (prefersReducedMotion) draw(4); }; const start = () => { if (!frame && visible && !document.hidden) frame = requestAnimationFrame(render); }; const stop = () => { if (frame) cancelAnimationFrame(frame); frame = 0; }; const onVisibilityChange = () => { if (document.hidden) stop(); else start(); }; const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host); resize(); let intersection: IntersectionObserver | undefined; if (!prefersReducedMotion) { intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) start(); else stop(); }); intersection.observe(host); document.addEventListener("visibilitychange", onVisibilityChange); start(); } return () => { stop(); resizeObserver.disconnect(); intersection?.disconnect(); document.removeEventListener("visibilitychange", onVisibilityChange); gl.deleteBuffer(buffer); gl.deleteShader(vertex); gl.deleteShader(fragment); gl.deleteProgram(program); }; }, [prefersReducedMotion, theme]); return ( ); } ``` --- # Light Cut > A WebGL shader of a beam of light cutting through fog, tinted from your theme, with five scenes. Install: `npx @modus-ui/cli add light-cut` A full-bleed WebGL shader of a beam of light through drifting fog that tilts toward the pointer, tinted from the palette's primary on the page ground. It opens on the prism aperture; switch to the matrix field, atmospheric blade, vanishing array or halftone relay. It fills its positioned parent, re-reads the theme when the mode or palette changes, pauses when off screen and stays still when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add light-cut ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/light-cut.tsx`. 3. Paste the code. ## Usage ```tsx import { LightCut } from "@/components/ui/light-cut"; export default function Example() { return (
); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :----------------------------------------------------------------------------------------------------------- | :----------------------- | :-------------------------------------------- | | `variant` | `"matrix-field"` \| `"atmospheric-blade"` \| `"vanishing-array"` \| `"prism-aperture"` \| `"halftone-relay"` | `"prism-aperture"` | Which scene to draw. | | `speed` | `number` | `1` | Animation speed (0 to 3). | | `size` | `number` | `1` | Beam width (0.35 to 2.5). | | `length` | `number` | `1` | Beam length (0.35 to 2.5). | | `density` | `number` | `1` | Fog and detail density (0.25 to 2.5). | | `opacity` | `number` | `1` | Canvas opacity (0.05 to 1). | | `hue` | `number` | `0` | Hue shift in degrees (-180 to 180). | | `saturation` | `number` | `1` | Colour saturation (0 to 2). | | `brightness` | `number` | `1` | Brightness (0.35 to 1.65). | | `backgroundColor` | `string` | `hsl(var(--background))` | Background behind the canvas, any CSS colour. | ## Source `components/ui/light-cut.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Laser (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export type LightCutVariant = "matrix-field" | "atmospheric-blade" | "vanishing-array" | "prism-aperture" | "halftone-relay"; export interface LightCutProps { variant?: LightCutVariant; speed?: number; size?: number; length?: number; density?: number; opacity?: number; hue?: number; saturation?: number; brightness?: number; /** Colour behind the beams; defaults to the theme background. */ backgroundColor?: string; className?: string; } type Hsl = [number, number, number]; /** Theme tokens are bare HSL channels ("24 8% 20%"). */ function readTheme() { const style = getComputedStyle(document.documentElement); const read = (name: string): Hsl => { const [h = 0, s = 0, l = 0] = style .getPropertyValue(name) .trim() .split(/[\s,/%]+/) .map(Number); return [h, s, l]; }; const background = read("--background"); return { dark: background[2] < 50, background, foreground: read("--foreground"), primary: read("--primary"), muted: read("--muted-foreground"), border: read("--border"), }; } /** Bumps on light/dark or palette changes so colours re-resolve. */ function useThemeVersion() { const [version, setVersion] = useState(0); useEffect(() => { const observer = new MutationObserver(() => setVersion((v) => v + 1)); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return version; } /** HSL channels to sRGB 0–1 for shader uniforms. */ function toRgb([h, s, l]: Hsl): [number, number, number] { const sat = Math.min(100, Math.max(0, s)) / 100; const lig = Math.min(100, Math.max(0, l)) / 100; const k = (n: number) => (n + h / 30) % 12; const a = sat * Math.min(lig, 1 - lig); const f = (n: number) => lig - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); return [f(0), f(8), f(4)]; } const VARIANT_INDEX: Record, number> = { "atmospheric-blade": 0, "vanishing-array": 1, "prism-aperture": 2, "halftone-relay": 3, }; const VERTEX_SHADER = ` attribute vec2 a_position; void main() { gl_Position = vec4(a_position, 0.0, 1.0); } `; const FRAGMENT_SHADER = ` precision highp float; uniform vec2 u_resolution; uniform vec2 u_pointer; uniform float u_time; uniform float u_variant; uniform float u_size; uniform float u_length; uniform float u_density; uniform float u_hue; uniform float u_saturation; uniform float u_brightness; uniform vec3 u_bg; uniform vec3 u_ink; uniform vec3 u_hot; // Maps emitted light onto the theme: ground -> ink -> hot core (ink on paper in light mode). vec3 themed(vec3 light) { float lum = clamp(max(light.r, max(light.g, light.b)), 0.0, 1.0); vec3 tone = mix(u_ink, u_hot, smoothstep(0.55, 1.0, lum)); return mix(u_bg, tone, smoothstep(0.02, 0.85, lum)); } float hash21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } float valueNoise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); return mix( mix(hash21(i), hash21(i + vec2(1.0, 0.0)), u.x), mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0)), u.x), u.y ); } float fbm(vec2 p) { float result = 0.0; float weight = 0.54; for (int i = 0; i < 4; i++) { result += valueNoise(p) * weight; p = mat2(1.62, 1.21, -1.21, 1.62) * p + 9.13; weight *= 0.48; } return result; } vec3 hsv2rgb(vec3 c) { vec3 p = abs(fract(c.xxx + vec3(0.0, 0.6666667, 0.3333333)) * 6.0 - 3.0); return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y); } vec3 accent(float baseHue, float saturationScale) { float hue = fract(baseHue + u_hue / 360.0); return hsv2rgb(vec3(hue, clamp(u_saturation * saturationScale, 0.0, 1.0), 1.0)); } vec2 beamProfile(float distanceToLine, float coreWidth, float glowWidth) { float core = exp(-pow(distanceToLine / max(coreWidth, 0.0002), 2.0)); float glow = exp(-pow(distanceToLine / max(glowWidth, 0.001), 1.25)); return vec2(core, glow); } vec3 atmosphericBlade(vec2 p) { float drift = sin(u_time * 0.21) * 0.025; float center = u_pointer.x * 0.16 + drift; float tilt = u_pointer.x * 0.055 + sin(u_time * 0.13) * 0.018; float distanceToBeam = abs(p.x - center - p.y * tilt); float verticalMask = 1.0 - smoothstep(0.68 * u_length, 1.35 * u_length, abs(p.y)); vec2 beam = beamProfile(distanceToBeam, 0.0028 * u_size, 0.052 * u_size); vec2 fogUv = vec2(p.x * 3.4, p.y * 2.15 - u_time * 0.075); fogUv.x += sin(p.y * 3.2 - u_time * 0.17) * 0.18; float fogNoise = fbm(fogUv + u_pointer * 0.35); float fogEnvelope = exp(-pow(distanceToBeam / (0.24 * u_size), 1.35)); float fog = smoothstep(0.34, 0.78, fogNoise) * fogEnvelope * u_density * verticalMask; float mirageDistance = abs(abs(p.x - center + p.y * tilt * 0.35) - 0.105 * u_length); vec2 mirage = beamProfile(mirageDistance, 0.0011 * u_size, 0.016 * u_size); float mirageMask = (1.0 - smoothstep(0.08, 1.2, abs(p.y))) * (0.35 + 0.65 * fogNoise); vec3 tint = accent(0.43, 0.82); vec3 color = vec3(0.0025, 0.004, 0.006); color += tint * fog * 0.34; color += tint * beam.y * verticalMask * (0.48 + 0.06 * sin(u_time * 1.1)); color += mix(tint, vec3(1.0), 0.88) * beam.x * verticalMask * 1.65; color += tint * mirage.y * mirageMask * 0.12 * u_density; color += vec3(0.8, 0.98, 0.9) * mirage.x * mirageMask * 0.35 * u_density; float contact = exp(-length(vec2((p.x - center) * 2.5, p.y + 0.77 * u_length)) * 7.0); color += tint * contact * 0.45; return color; } vec3 vanishingArray(vec2 p) { vec2 origin = vec2(u_pointer.x * 0.16, 0.12 + u_pointer.y * 0.075); vec2 q = p - origin; float radius = length(q); float angle = atan(q.y, q.x); float spokeCount = floor(11.0 + u_density * 10.0); float angularDistance = abs(sin(angle * spokeCount)); float spoke = exp(-angularDistance * max(radius, 0.06) / (0.0075 * u_size)); float reach = smoothstep(0.035, 0.16, radius) * (1.0 - smoothstep(0.42 * u_length, 1.55 * u_length, radius)); float lowerField = 1.0 - smoothstep(-0.12, 0.22, q.y); float upperField = smoothstep(0.02, 0.52, q.y) * 0.48; float fieldMask = max(lowerField, upperField); float carrier = 0.55 + 0.45 * sin(radius * 16.0 - u_time * 4.1 + angle * 2.0); carrier = pow(max(carrier, 0.0), 7.0); float rail = spoke * reach * fieldMask; float railCore = pow(rail, 2.1); float ringPhase = abs(sin((radius * 13.0 - u_time * 1.5) / max(u_length, 0.35))); float rings = exp(-ringPhase / (0.035 * u_size)) * (1.0 - smoothstep(0.1, 1.2, radius)) * lowerField; float horizon = exp(-abs(q.y) / (0.0035 * u_size)) * (1.0 - smoothstep(0.12, 1.15, abs(q.x))); vec3 violet = accent(0.70, 0.78); vec3 amber = accent(0.055, 0.84); vec3 railTint = mix(violet, amber, 0.5 + 0.5 * sin(angle * 3.0)); vec3 color = vec3(0.004, 0.0035, 0.009); color += railTint * rail * (0.24 + carrier * 0.72); color += mix(railTint, vec3(1.0), 0.9) * railCore * (0.52 + carrier * 0.92); color += violet * rings * 0.14 * u_density; color += vec3(0.82, 0.9, 1.0) * horizon * 0.38; color += violet * exp(-radius * 15.0) * 0.95; return color; } vec3 prismAperture(vec2 p) { vec2 center = u_pointer * vec2(0.12, 0.08); vec2 q = p - center; float breathing = 0.46 * u_length + sin(u_time * 0.72) * 0.012; float warp = (fbm(q * 3.2 + vec2(0.0, -u_time * 0.08)) - 0.5) * 0.025 * u_density; float diamond = abs(q.x * 0.82) + abs(q.y) - breathing - warp; float innerDiamond = abs(q.x * 0.82) + abs(q.y) - breathing * 0.66 + warp * 0.45; vec2 outer = beamProfile(abs(diamond), 0.0024 * u_size, 0.046 * u_size); vec2 inner = beamProfile(abs(innerDiamond), 0.0012 * u_size, 0.018 * u_size); float edgeMask = 1.0 - smoothstep(0.25, 1.18, length(q)); float perimeterPhase = sin((q.x - q.y) * 15.0 - u_time * 3.3); float packets = pow(max(perimeterPhase, 0.0), 10.0) * outer.y; float axisX = exp(-abs(q.x) / (0.002 * u_size)) * (1.0 - smoothstep(0.04, breathing, abs(q.y))); float axisY = exp(-abs(q.y) / (0.002 * u_size)) * (1.0 - smoothstep(0.04, breathing, abs(q.x))); float redFringe = exp(-pow(abs(diamond - 0.011 * u_size) / (0.011 * u_size), 1.4)); float blueFringe = exp(-pow(abs(diamond + 0.011 * u_size) / (0.011 * u_size), 1.4)); vec3 tint = accent(0.79, 0.8); vec3 color = vec3(0.004, 0.0025, 0.007); color += tint * outer.y * edgeMask * 0.5; color += mix(tint, vec3(1.0), 0.9) * outer.x * edgeMask * 1.5; color += tint * inner.y * edgeMask * 0.18 * u_density; color += vec3(1.0) * inner.x * edgeMask * 0.48 * u_density; color += accent(0.98, 0.92) * redFringe * 0.13; color += accent(0.54, 0.9) * blueFringe * 0.16; color += vec3(0.92, 0.96, 1.0) * (axisX + axisY) * 0.2; color += tint * packets * 0.85; color += tint * exp(-length(q) * 9.0) * 0.22; return color; } vec3 halftoneRelay(vec2 p) { float center = 0.29 + u_pointer.x * 0.12; float bend = sin(p.y * 2.1 - u_time * 0.25) * 0.018; float mainDistance = abs(p.x - center - bend); float relayDistance = abs(p.x - center + 0.075 * u_length + bend * 0.45); vec2 mainBeam = beamProfile(mainDistance, 0.0022 * u_size, 0.072 * u_size); vec2 relayBeam = beamProfile(relayDistance, 0.0012 * u_size, 0.025 * u_size); vec2 fogUv = vec2(p.x * 3.0, p.y * 2.5 - u_time * 0.1); float fogNoise = fbm(fogUv + vec2(sin(u_time * 0.16), 0.0)); float fog = smoothstep(0.28, 0.78, fogNoise) * exp(-mainDistance * 9.5 / u_size) * u_density; float dotScale = mix(9.0, 4.5, clamp((u_density - 0.25) / 2.25, 0.0, 1.0)); vec2 dotCell = fract(gl_FragCoord.xy / dotScale) - 0.5; float dot = 1.0 - smoothstep(0.08, 0.34, length(dotCell)); float dotMask = dot * smoothstep(0.08, 0.68, fog + mainBeam.y * 0.65); float pulse = pow(max(0.0, sin(p.y * 9.0 - u_time * 4.0)), 12.0); float horizontalRelay = exp(-abs(p.y + 0.34 - u_pointer.y * 0.08) / (0.0024 * u_size)); horizontalRelay *= 1.0 - smoothstep(0.1, 1.05 * u_length, abs(p.x - center)); vec3 tint = accent(0.54, 0.84); vec3 color = vec3(0.002, 0.005, 0.009); color += tint * fog * 0.24; color += tint * mainBeam.y * 0.54; color += mix(tint, vec3(1.0), 0.9) * mainBeam.x * 1.48; color += tint * relayBeam.y * 0.2; color += vec3(0.78, 0.95, 1.0) * relayBeam.x * 0.5; color += tint * dotMask * (0.3 + pulse * 0.42); color += mix(tint, vec3(1.0), 0.8) * horizontalRelay * (0.22 + pulse * 0.58); return color; } void main() { vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution.xy) / min(u_resolution.x, u_resolution.y); vec3 color; if (u_variant < 0.5) { color = atmosphericBlade(p); } else if (u_variant < 1.5) { color = vanishingArray(p); } else if (u_variant < 2.5) { color = prismAperture(p); } else { color = halftoneRelay(p); } float vignette = 1.0 - smoothstep(0.24, 1.45, length(p * vec2(0.72, 0.88))); color *= 0.55 + vignette * 0.45; color *= u_brightness; color = color / (color + vec3(0.72)); color = pow(max(color, vec3(0.0)), vec3(0.82)); gl_FragColor = vec4(themed(color), 1.0); } `; // Matrix Field (ThreeUI "Matrix Junction") keeps its original standalone shader. const MATRIX_FIELD_SHADER = ` precision highp float; uniform vec3 u_bg; uniform vec3 u_ink; uniform vec3 u_hot; // Maps emitted light onto the theme: ground -> ink -> hot core (ink on paper in light mode). vec3 themed(vec3 light) { float lum = clamp(max(light.r, max(light.g, light.b)), 0.0, 1.0); vec3 tone = mix(u_ink, u_hot, smoothstep(0.55, 1.0, lum)); return mix(u_bg, tone, smoothstep(0.02, 0.85, lum)); } uniform vec2 u_resolution; uniform float u_time; uniform vec2 u_mouse; uniform float u_mouseActive; uniform float u_intensity; float hash(float n) { return fract(sin(n)*753.5453123); } float noise(float x) { float i = floor(x); float f = fract(x); f = f*f*(3.0-2.0*f); return mix(hash(i), hash(i+1.0), f); } vec2 sdLine(vec2 p, vec2 a, vec2 b) { vec2 pa = p - a, ba = b - a; float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); return vec2(length(pa - ba * h), h); } float lightning(vec2 uv, vec2 a, vec2 b, float t) { vec2 ab = b - a; float len = length(ab); if(len < 0.01) return 0.0; vec2 dir = ab / len; vec2 pa = uv - a; float h = clamp(dot(pa, dir) / len, 0.0, 1.0); float dist = length(pa - dir * (h * len)); float env = sin(h * 3.1415); float offset = (noise(h * 25.0 - t * 35.0) - 0.5) * 0.08 * env; offset += (noise(h * 70.0 + t * 50.0) - 0.5) * 0.02 * env; float d = abs(dist + offset); return (0.0002 / (d + 0.0002) + 0.00001 / (d*d + 0.00001)) * env; } void main() { vec2 uv = gl_FragCoord.xy / u_resolution.xy; uv = uv * 2.0 - 1.0; uv.x *= u_resolution.x / u_resolution.y; vec2 mouseUV = u_mouse / u_resolution.xy; mouseUV = mouseUV * 2.0 - 1.0; mouseUV.x *= u_resolution.x / u_resolution.y; vec2 center = vec2(-0.8, -0.2); center.x += sin(u_time * 0.4) * 0.03; center.y += cos(u_time * 0.3) * 0.03; vec2 dirUp = normalize(vec2(0.15, 1.0)); vec2 dirRight = normalize(vec2(1.0, -0.25)); vec2 dirDownLeft = normalize(vec2(-0.8, -0.6)); vec2 l1 = sdLine(uv, center, center + dirUp * 5.0); vec2 l2 = sdLine(uv, center, center + dirRight * 5.0); vec2 l3 = sdLine(uv, center, center + dirDownLeft * 5.0); float intensity = u_intensity; float glow = intensity / (l1.x + 0.001) + intensity / (l2.x + 0.001) + (intensity * 0.4) / (l3.x + 0.001); float pulse1 = smoothstep(0.1, 0.0, abs(l1.y - fract(u_time * 0.4))) * 0.03 / (l1.x + 0.001); float pulse2 = smoothstep(0.1, 0.0, abs(l2.y - fract(u_time * 0.5 + 0.3))) * 0.03 / (l2.x + 0.001); float pulse3 = smoothstep(0.1, 0.0, abs(l3.y - fract(u_time * 0.3 + 0.7))) * 0.015 / (l3.x + 0.001); glow += pulse1 + pulse2 + pulse3; vec2 p1 = center + dirUp * clamp(dot(mouseUV - center, dirUp), 0.0, 5.0); vec2 p2 = center + dirRight * clamp(dot(mouseUV - center, dirRight), 0.0, 5.0); vec2 p3 = center + dirDownLeft * clamp(dot(mouseUV - center, dirDownLeft), 0.0, 5.0); float lgt1 = lightning(uv, p1, mouseUV, u_time); float lgt2 = lightning(uv, p2, mouseUV, u_time + 10.0); float lgt3 = lightning(uv, p3, mouseUV, u_time + 20.0); float flicker = step(0.1, noise(u_time * 60.0)) * (noise(u_time * 150.0) * 0.8 + 0.2); float d1 = length(mouseUV - p1); float d2 = length(mouseUV - p2); float d3 = length(mouseUV - p3); glow += lgt1 * smoothstep(2.0, 0.0, d1) * u_mouseActive * flicker; glow += lgt2 * smoothstep(2.0, 0.0, d2) * u_mouseActive * flicker; glow += lgt3 * smoothstep(2.0, 0.0, d3) * u_mouseActive * flicker; float distToCenter = length(uv - center); glow += 0.04 / (distToCenter + 0.01); vec3 baseColor = vec3(0.6, 0.75, 1.0); vec3 finalColor = baseColor * glow; finalColor *= 0.85 + 0.15 * sin(u_time * 2.0 - distToCenter * 8.0); float vignette = 1.0 - smoothstep(0.4, 2.0, length(uv)); finalColor *= vignette; float n = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453); finalColor += n * 0.02; gl_FragColor = vec4(themed(finalColor), 1.0); } `; function clamp(value: number, minimum: number, maximum: number) { return Math.min(maximum, Math.max(minimum, value)); } function compile(gl: WebGLRenderingContext, type: number, source: string) { const shader = gl.createShader(type); if (!shader) throw new Error("Unable to create Light Cut shader"); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const message = gl.getShaderInfoLog(shader) ?? "Light Cut shader compilation failed"; gl.deleteShader(shader); throw new Error(message); } return shader; } export function LightCut({ variant = "prism-aperture", speed = 1, size = 1, length = 1, density = 1, opacity = 1, hue = 0, saturation = 1, brightness = 1, backgroundColor, className, }: LightCutProps) { const hostRef = useRef(null); const canvasRef = useRef(null); const redrawRef = useRef<() => void>(() => undefined); const prefersReducedMotion = useReducedMotion(); const themeVersion = useThemeVersion(); const optionsRef = useRef({ variant, speed, size, length, density, hue, saturation, brightness }); optionsRef.current = { variant, speed, size, length, density, hue, saturation, brightness }; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; if (!host || !canvas) return; const gl = canvas.getContext("webgl", { alpha: false, antialias: false, powerPreference: "high-performance", premultipliedAlpha: false, }); if (!gl) return; const vertexShader = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER); const fragmentShader = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); const matrixShader = compile(gl, gl.FRAGMENT_SHADER, MATRIX_FIELD_SHADER); const link = (fragment: WebGLShader) => { const linked = gl.createProgram(); if (!linked) throw new Error("Unable to create Light Cut program"); gl.attachShader(linked, vertexShader); gl.attachShader(linked, fragment); gl.bindAttribLocation(linked, 0, "a_position"); gl.linkProgram(linked); if (!gl.getProgramParameter(linked, gl.LINK_STATUS)) { const message = gl.getProgramInfoLog(linked) ?? "Light Cut program link failed"; gl.deleteProgram(linked); throw new Error(message); } return linked; }; const program = link(fragmentShader); const matrixProgram = link(matrixShader); const theme = readTheme(); const [ph, ps, pl] = theme.primary; const ground = toRgb(theme.background); const ink = toRgb([ ph, Math.max(ps, 45), theme.dark ? Math.max(pl, 58) : Math.min(Math.max(pl, 32), 46), ]); const hot = theme.dark ? toRgb([ph + 20, 40, 94]) : toRgb(theme.foreground); for (const linked of [program, matrixProgram]) { gl.useProgram(linked); gl.uniform3fv(gl.getUniformLocation(linked, "u_bg"), ground); gl.uniform3fv(gl.getUniformLocation(linked, "u_ink"), ink); gl.uniform3fv(gl.getUniformLocation(linked, "u_hot"), hot); } const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW, ); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); const uniforms = { resolution: gl.getUniformLocation(program, "u_resolution"), pointer: gl.getUniformLocation(program, "u_pointer"), time: gl.getUniformLocation(program, "u_time"), variant: gl.getUniformLocation(program, "u_variant"), size: gl.getUniformLocation(program, "u_size"), length: gl.getUniformLocation(program, "u_length"), density: gl.getUniformLocation(program, "u_density"), hue: gl.getUniformLocation(program, "u_hue"), saturation: gl.getUniformLocation(program, "u_saturation"), brightness: gl.getUniformLocation(program, "u_brightness"), }; const matrixUniforms = { resolution: gl.getUniformLocation(matrixProgram, "u_resolution"), time: gl.getUniformLocation(matrixProgram, "u_time"), mouse: gl.getUniformLocation(matrixProgram, "u_mouse"), mouseActive: gl.getUniformLocation(matrixProgram, "u_mouseActive"), intensity: gl.getUniformLocation(matrixProgram, "u_intensity"), }; let frame = 0; let mouseX = -1000; let mouseY = -1000; let lastMouseMove = -Infinity; let mouseActive = 0; let visible = true; let targetX = 0; let targetY = 0; let pointerX = 0; let pointerY = 0; const startedAt = performance.now(); const draw = (now: number) => { const options = optionsRef.current; const time = prefersReducedMotion ? 2.75 : (now - startedAt) * 0.001 * clamp(options.speed, 0, 3); if (options.variant === "matrix-field") { const sinceMove = now - lastMouseMove; const targetActive = prefersReducedMotion ? 0 : sinceMove < 150 ? 1 : Math.max(0, 1 - (sinceMove - 150) / 350); mouseActive += (targetActive - mouseActive) * 0.15; gl.useProgram(matrixProgram); gl.uniform2f(matrixUniforms.resolution, canvas.width, canvas.height); gl.uniform1f(matrixUniforms.time, time); gl.uniform2f(matrixUniforms.mouse, mouseX, mouseY); gl.uniform1f(matrixUniforms.mouseActive, mouseActive); gl.uniform1f( matrixUniforms.intensity, 0.006 * clamp(options.size, 0.05, 200) * clamp(options.length, 0.35, 2.5), ); gl.drawArrays(gl.TRIANGLES, 0, 6); return; } const index = VARIANT_INDEX[options.variant] ?? 0; pointerX += (targetX - pointerX) * (prefersReducedMotion ? 1 : 0.055); pointerY += (targetY - pointerY) * (prefersReducedMotion ? 1 : 0.055); gl.useProgram(program); gl.uniform2f(uniforms.resolution, canvas.width, canvas.height); gl.uniform2f(uniforms.pointer, pointerX, pointerY); gl.uniform1f(uniforms.time, time); gl.uniform1f(uniforms.variant, index); gl.uniform1f(uniforms.size, clamp(options.size, 0.35, 2.5)); gl.uniform1f(uniforms.length, clamp(options.length, 0.35, 2.5)); gl.uniform1f(uniforms.density, clamp(options.density, 0.25, 2.5)); gl.uniform1f(uniforms.hue, clamp(options.hue, -180, 180)); gl.uniform1f(uniforms.saturation, clamp(options.saturation, 0, 2)); gl.uniform1f(uniforms.brightness, clamp(options.brightness, 0.35, 1.65)); gl.drawArrays(gl.TRIANGLES, 0, 6); }; const render = (now: number) => { frame = 0; draw(now); if (!prefersReducedMotion && visible && !document.hidden) { frame = requestAnimationFrame(render); } }; const sync = () => { if (prefersReducedMotion || !visible || document.hidden) { if (frame) cancelAnimationFrame(frame); frame = 0; draw(performance.now()); return; } if (!frame) frame = requestAnimationFrame(render); }; redrawRef.current = () => draw(performance.now()); const resize = () => { const bounds = host.getBoundingClientRect(); const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5); canvas.width = Math.max(1, Math.round(bounds.width * pixelRatio)); canvas.height = Math.max(1, Math.round(bounds.height * pixelRatio)); gl.viewport(0, 0, canvas.width, canvas.height); draw(performance.now()); }; // Listen on window so content layered above the background does not block the beam. const handlePointerMove = (event: PointerEvent) => { const bounds = host.getBoundingClientRect(); const inside = event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom; if (inside) { const pixelRatio = canvas.width / Math.max(1, bounds.width); mouseX = (event.clientX - bounds.left) * pixelRatio; mouseY = (bounds.bottom - event.clientY) * pixelRatio; lastMouseMove = performance.now(); targetX = ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1; targetY = -(((event.clientY - bounds.top) / Math.max(1, bounds.height)) * 2 - 1); } else { targetX = 0; targetY = 0; } if (prefersReducedMotion) draw(performance.now()); }; const handlePointerLeave = () => { targetX = 0; targetY = 0; if (prefersReducedMotion) draw(performance.now()); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; sync(); }); resizeObserver.observe(host); intersection.observe(host); window.addEventListener("pointermove", handlePointerMove, { passive: true }); document.documentElement.addEventListener("pointerleave", handlePointerLeave); document.addEventListener("visibilitychange", sync); resize(); sync(); return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); intersection.disconnect(); window.removeEventListener("pointermove", handlePointerMove); document.documentElement.removeEventListener("pointerleave", handlePointerLeave); document.removeEventListener("visibilitychange", sync); redrawRef.current = () => undefined; gl.deleteBuffer(buffer); gl.deleteShader(vertexShader); gl.deleteShader(fragmentShader); gl.deleteShader(matrixShader); gl.deleteProgram(program); gl.deleteProgram(matrixProgram); }; }, [prefersReducedMotion, themeVersion]); useEffect(() => { redrawRef.current(); }, [variant, speed, size, length, density, hue, saturation, brightness]); return ( ); } ``` --- # Stipple Tide > A WebGL stipple of theme-coloured dots that swell in slow rings from the centre and drift with the pointer. Install: `npx @modus-ui/cli add stipple-tide` A full-bleed Three.js stipple that sits on the page's own background: dots rest in the muted ink colour and swell into the primary colour as slow rings travel outward from the centre, fading toward the edges. It follows the active palette and light or dark mode, and shifting slightly as the pointer moves anywhere over the page. It fills its positioned parent, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add stipple-tide ``` ### Manual 1. Install `three`. 2. Copy the code from the component tab. 3. Create a file at `components/ui/stipple-tide.tsx`. 4. Paste the code. ## Usage ```tsx import { StippleTide } from "@/components/ui/stipple-tide"; export default function Example() { return (

Content over the effect

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :------- | :------------- | :---------------------------------------------------------- | | `speed` | `number` | `1` | Animation speed multiplier. | | `gridScale` | `number` | `36` | Number of dot cells across the height. | | `mouseAmount` | `number` | `0.03` | How far the grid shifts with the pointer. | | `pulseSpeed` | `number` | `0.25` | Rings per second travelling outward. | | `radius` | `number` | `0.26` | Extra dot radius at the peak of a pulse. | | `opacity` | `number` | `0.9` | Overall opacity of the dots. | | `hue` | `number` | `0` | Hue rotation in degrees. | | `color` | `string` | `--primary` | Dot colour at the crest of a ring (theme token by default). | | `backgroundColor` | `string` | `--background` | Background behind the dots (theme token by default). | ## Source `components/ui/stipple-tide.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Dot Matrix (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import * as THREE from "three"; import { cn } from "@/lib/utils"; type Tokens = { primary: string; foreground: string; muted: string; background: string; card: string; border: string; dark: boolean; }; /** Reads the Modus theme tokens (bare HSL channels) as comma `hsl()` strings canvas and three.js both parse. */ function readTokens(): Tokens { const root = document.documentElement; const cs = getComputedStyle(root); const hsl = (name: string) => `hsl(${cs .getPropertyValue(name) .trim() .split(/[\s,]+/) .filter(Boolean) .join(", ")})`; return { primary: hsl("--primary"), foreground: hsl("--foreground"), muted: hsl("--muted-foreground"), background: hsl("--background"), card: hsl("--card"), border: hsl("--border"), dark: root.classList.contains("dark") || root.dataset.theme === "dark", }; } /** The current theme tokens, re-read whenever the mode or palette on changes. */ function useTokens() { const [tokens, setTokens] = useState(null); useEffect(() => { const update = () => setTokens(readTokens()); update(); const observer = new MutationObserver(update); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return tokens; } export interface StippleTideProps { speed?: number; gridScale?: number; mouseAmount?: number; pulseSpeed?: number; radius?: number; opacity?: number; hue?: number; /** Dot colour at the crest of a ripple. Defaults to the theme's `--primary`. */ color?: string; /** Background behind the dots. Defaults to the theme's `--background`. */ backgroundColor?: string; className?: string; } const VERTEX_SHADER = ` varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 1.0); } `; const FRAGMENT_SHADER = ` uniform float uTime; uniform vec2 uResolution; uniform vec2 uMouse; uniform float uGridScale; uniform float uMouseAmount; uniform float uPulseSpeed; uniform float uRadius; uniform float uOpacity; uniform vec3 uColor; uniform vec3 uBase; varying vec2 vUv; void main() { vec2 uv = gl_FragCoord.xy / uResolution.xy; float aspect = uResolution.x / uResolution.y; uv.x *= aspect; uv += uMouse * uMouseAmount; vec2 grid = fract(uv * uGridScale); vec2 id = floor(uv * uGridScale); float dist = length(grid - vec2(0.5)); vec2 center = vec2(0.5 * aspect, 0.5); float ring = length(id - center * uGridScale); float pulse = sin(uTime * uPulseSpeed * 6.2831 - ring * 0.32) * 0.5 + 0.5; pulse = pow(pulse, 2.2); float radius = 0.06 + pulse * uRadius; float alpha = smoothstep(radius, radius - 0.05, dist); float depthFade = smoothstep(1.05, 0.05, length(uv - center)); vec3 color = mix(uBase, uColor, pulse); gl_FragColor = vec4(color, alpha * mix(0.35, 1.0, pulse) * depthFade * uOpacity); } `; export function StippleTide({ speed = 1, gridScale = 36, mouseAmount = 0.03, pulseSpeed = 0.25, radius = 0.26, opacity = 0.9, hue = 0, color, backgroundColor, className, }: StippleTideProps) { const prefersReducedMotion = useReducedMotion(); const tokens = useTokens(); const ink = color ?? tokens?.primary ?? "currentColor"; const base = tokens?.muted ?? ink; const hostRef = useRef(null); const canvasRef = useRef(null); const options = useRef({ speed, gridScale, mouseAmount, pulseSpeed, radius, opacity, ink, base }); options.current = { speed, gridScale, mouseAmount, pulseSpeed, radius, opacity, ink, base }; const redraw = useRef<() => void>(() => {}); useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; if (!host || !canvas) return; const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); const scene = new THREE.Scene(); const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1; const uniforms = { uTime: { value: 0 }, uResolution: { value: new THREE.Vector2() }, uMouse: { value: new THREE.Vector2() }, uGridScale: { value: 60 }, uMouseAmount: { value: 0.04 }, uPulseSpeed: { value: 0.4 }, uRadius: { value: 0.15 }, uOpacity: { value: 0.35 }, uColor: { value: new THREE.Color() }, uBase: { value: new THREE.Color() }, }; const geometry = new THREE.PlaneGeometry(2, 2); const material = new THREE.ShaderMaterial({ uniforms, vertexShader: VERTEX_SHADER, fragmentShader: FRAGMENT_SHADER, transparent: true, depthWrite: false, }); scene.add(new THREE.Mesh(geometry, material)); const mouse = new THREE.Vector2(); const target = new THREE.Vector2(); const startedAt = performance.now(); let frame = 0; let visible = true; // Listen on window so overlaid content does not block the pointer drift. const onPointer = (event: PointerEvent) => { const bounds = canvas.getBoundingClientRect(); target.x = ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1; target.y = -(((event.clientY - bounds.top) / Math.max(1, bounds.height)) * 2 - 1); }; const draw = (now: number) => { const o = options.current; mouse.lerp(target, 0.05); uniforms.uTime.value = (now - startedAt) * 0.001 * o.speed; uniforms.uMouse.value = mouse; uniforms.uGridScale.value = o.gridScale; uniforms.uMouseAmount.value = o.mouseAmount; uniforms.uPulseSpeed.value = o.pulseSpeed; uniforms.uRadius.value = o.radius; uniforms.uOpacity.value = o.opacity; if (o.ink !== "currentColor") { uniforms.uColor.value.setStyle(o.ink, THREE.LinearSRGBColorSpace); uniforms.uBase.value.setStyle(o.base, THREE.LinearSRGBColorSpace); } renderer.render(scene, camera); }; const tick = (now: number) => { draw(now); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const start = () => { if (!prefersReducedMotion && visible && !frame) frame = requestAnimationFrame(tick); }; const resize = () => { const { width, height } = host.getBoundingClientRect(); renderer.setSize(width, height, false); uniforms.uResolution.value.set(width, height); if (prefersReducedMotion) draw(startedAt); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) start(); else if (frame) { cancelAnimationFrame(frame); frame = 0; } }); const onVisibility = () => !document.hidden && start(); document.addEventListener("visibilitychange", onVisibility); redraw.current = () => { if (prefersReducedMotion) draw(startedAt); }; resizeObserver.observe(host); intersection.observe(host); if (!prefersReducedMotion) window.addEventListener("pointermove", onPointer, { passive: true }); resize(); start(); return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("pointermove", onPointer); geometry.dispose(); material.dispose(); renderer.dispose(); }; }, [prefersReducedMotion]); // A still frame has to be repainted when the theme changes under it. useEffect(() => { redraw.current(); }, [ink, base]); return (
); } ``` --- # Tide Line > A slow band of ink-toned light rolling along the top edge, tinted from your theme. Install: `npx @modus-ui/cli add tide-line` A full-bleed Three.js shader of a soft band of light that hangs from the top edge and rolls in slow waves. Its two inks are derived from the theme's primary hue, so it sits on paper in light mode and glows on the dark background in dark mode, following the palette picker. It fills its positioned parent, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add tide-line ``` ### Manual 1. Install `three`. 2. Copy the code from the component tab. 3. Create a file at `components/ui/tide-line.tsx`. 4. Paste the code. ## Usage ```tsx import { TideLine } from "@/components/ui/tide-line"; export default function Example() { return (

Content over the effect

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :----------------- | :----------------- | :------------------------------------------------------ | | `speed` | `number` | `1` | Animation speed multiplier. | | `waveScale` | `number` | `1` | Height of the horizon waves. | | `variation` | `number` | `1` | Strength of the brightness variation along the horizon. | | `glow` | `number` | `1` | Glow intensity. | | `vignette` | `number` | `1` | Vignette strength, from 0 (off) to 1. | | `hue` | `number` | `0` | Hue rotation in degrees. | | `colors` | `[string, string]` | theme primary ramp | The two glow colours blended across the band. | | `backgroundColor` | `string` | theme background | Colour of the ground behind the glow. | ## Source `components/ui/tide-line.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Emerald Horizon (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import * as THREE from "three"; import { cn } from "@/lib/utils"; interface TideLineProps { speed?: number; waveScale?: number; variation?: number; glow?: number; vignette?: number; hue?: number; /** Two glow colours; defaults to a ramp derived from the theme's primary hue. */ colors?: [string, string]; /** Colour behind the glow; defaults to the theme background. */ backgroundColor?: string; className?: string; } const VERTEX_SHADER = ` varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 1.0); } `; const FRAGMENT_SHADER = ` uniform float u_time; uniform vec2 u_resolution; uniform float u_wave_scale; uniform float u_variation; uniform float u_glow; uniform float u_vignette; uniform vec3 u_base; uniform vec3 u_glow1; uniform vec3 u_glow2; varying vec2 vUv; float hash(float n) { return fract(sin(n) * 1e4); } float noise(float x) { float i = floor(x); float f = fract(x); float u = f * f * (3.0 - 2.0 * f); return mix(hash(i), hash(i + 1.0), u); } void main() { vec2 st = gl_FragCoord.xy / u_resolution.xy; float yPos = 1.0 - st.y; float wave1 = sin(st.x * 3.0 + u_time * 0.5) * 0.1 * u_wave_scale; float wave2 = sin(st.x * 5.0 - u_time * 0.3) * 0.05 * u_wave_scale; float combinedWave = wave1 + wave2; float intensity = smoothstep(0.4, -0.1, yPos + combinedWave); float variation = noise(st.x * 2.0 + u_time * 0.1) * 0.5 + 0.5; intensity *= variation * 1.5 * u_variation; vec3 finalGlow = mix(u_glow1, u_glow2, clamp(st.x + sin(u_time*0.2)*0.5, 0.0, 1.0)); // Ink mixed onto the ground, so it reads on paper as well as on a dark stage. vec3 color = mix(u_base, finalGlow, clamp(pow(intensity, 1.5) * 1.2 * u_glow, 0.0, 1.0)); float vignette = mix(1.0, smoothstep(1.2, 0.5, length(st - vec2(0.5, 1.0))), u_vignette); color = mix(u_base, color, vignette); gl_FragColor = vec4(color, 1.0); } `; type Hsl = [number, number, number]; /** Theme tokens are bare HSL channels ("24 8% 20%"). */ function readTheme() { const style = getComputedStyle(document.documentElement); const read = (name: string): Hsl => { const [h = 0, s = 0, l = 0] = style .getPropertyValue(name) .trim() .split(/[\s,/%]+/) .map(Number); return [h, s, l]; }; const background = read("--background"); return { dark: background[2] < 50, background, foreground: read("--foreground"), primary: read("--primary"), muted: read("--muted-foreground"), }; } /** Comma-separated hsl(), which both canvas and THREE.Color parse. */ const hsl = ([h, s, l]: Hsl, alpha = 1) => `hsla(${Math.round(h)}, ${Math.round(Math.min(100, Math.max(0, s)))}%, ${Math.round(Math.min(100, Math.max(0, l)))}%, ${alpha})`; /** Bumps on light/dark or palette changes so canvas colours re-resolve. */ function useThemeVersion() { const [version, setVersion] = useState(0); useEffect(() => { const observer = new MutationObserver(() => setVersion((v) => v + 1)); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return version; } /** Two inks on the primary hue, spread apart for a little colour movement. */ function themeGlow(): [string, string] { const { dark, primary } = readTheme(); const [h, s] = primary; const sat = Math.max(s, 38); return dark ? [hsl([h, sat, 58]), hsl([h - 22, sat + 10, 72])] : [hsl([h, sat, 30]), hsl([h - 22, sat + 6, 44])]; } export function TideLine({ speed = 1, waveScale = 1, variation = 1, glow = 1, vignette = 1, hue = 0, colors, backgroundColor, className, }: TideLineProps) { const prefersReducedMotion = useReducedMotion(); const hostRef = useRef(null); const canvasRef = useRef(null); const theme = useThemeVersion(); const options = useRef({ speed, waveScale, variation, glow, vignette }); options.current = { speed, waveScale, variation, glow, vignette }; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; if (!host || !canvas) return; const scene = new THREE.Scene(); const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); const uniforms = { u_time: { value: 0 }, u_resolution: { value: new THREE.Vector2(1, 1) }, u_wave_scale: { value: 1 }, u_variation: { value: 1 }, u_glow: { value: 1 }, u_vignette: { value: 1 }, u_base: { value: new THREE.Color() }, u_glow1: { value: new THREE.Color() }, u_glow2: { value: new THREE.Color() }, }; const material = new THREE.ShaderMaterial({ vertexShader: VERTEX_SHADER, fragmentShader: FRAGMENT_SHADER, uniforms, depthWrite: false, depthTest: false, }); const geometry = new THREE.PlaneGeometry(2, 2); scene.add(new THREE.Mesh(geometry, material)); const glowColors = colors ?? themeGlow(); const base = backgroundColor ?? hsl(readTheme().background); const startedAt = performance.now(); let frame = 0; let visible = true; const draw = (now: number) => { const o = options.current; uniforms.u_time.value = (now - startedAt) * 0.001 * o.speed; uniforms.u_wave_scale.value = o.waveScale; uniforms.u_variation.value = o.variation; uniforms.u_glow.value = o.glow; uniforms.u_vignette.value = o.vignette; // Colours go straight into the shader as sRGB values. uniforms.u_base.value.setStyle(base, THREE.LinearSRGBColorSpace); uniforms.u_glow1.value.setStyle(glowColors[0], THREE.LinearSRGBColorSpace); uniforms.u_glow2.value.setStyle(glowColors[1], THREE.LinearSRGBColorSpace); renderer.render(scene, camera); }; const tick = (now: number) => { draw(now); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const start = () => { if (!prefersReducedMotion && visible && !frame) frame = requestAnimationFrame(tick); }; const resize = () => { const { width, height } = host.getBoundingClientRect(); renderer.setSize(width, height, false); uniforms.u_resolution.value.set(width, height); if (prefersReducedMotion) draw(startedAt); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) start(); else if (frame) { cancelAnimationFrame(frame); frame = 0; } }); const onVisibility = () => !document.hidden && start(); document.addEventListener("visibilitychange", onVisibility); resizeObserver.observe(host); intersection.observe(host); resize(); start(); return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); geometry.dispose(); material.dispose(); renderer.dispose(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [prefersReducedMotion, theme, colors?.[0], colors?.[1], backgroundColor]); return (
); } ``` --- # Lattice Drift > A WebGL dome of ink points turning slowly under a fading mask, with scattered accent points in the theme primary. Install: `npx @modus-ui/cli add lattice-drift` A WebGL dome of ink points turning slowly under a fading mask, with scattered accent points in the theme primary. It fills its positioned parent, follows the active theme and palette in light and dark mode, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add lattice-drift ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/lattice-drift.tsx`. 3. Paste the code. ## Usage ```tsx import { LatticeDrift } from "@/components/ui/lattice-drift"; export default function Example() { return (

Content over the background

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :------- | :--------------- | :--------------------------------------------------------- | | `speed` | `number` | `0.7` | Rotation speed multiplier. | | `pointSize` | `number` | `0.11` | Size of each point. | | `opacity` | `number` | `0.75` | Opacity of the points. | | `maskStart` | `number` | `0.05` | Fraction of the height where the fade mask begins. | | `maskSolid` | `number` | `0.35` | Fraction of the height where the points are fully visible. | | `color` | `string` | theme foreground | Colour of most points. | | `accentColor` | `string` | theme primary | Colour of the scattered accent points. | | `backgroundColor` | `string` | theme background | Background behind the points. | ## Source `components/ui/lattice-drift.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Structure Flow (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import * as THREE from "three"; import { cn } from "@/lib/utils"; interface LatticeDriftProps { speed?: number; pointSize?: number; opacity?: number; maskStart?: number; maskSolid?: number; /** Colour of most points. Defaults to the theme foreground. */ color?: string; /** Colour of the scattered accent points. Defaults to the theme primary. */ accentColor?: string; /** Defaults to the theme background. */ backgroundColor?: string; className?: string; } type Rgb = [number, number, number]; const TOKENS = [ "background", "foreground", "primary", "muted-foreground", "border", "card", ] as const; type Theme = Record<(typeof TOKENS)[number], Rgb>; /** Any CSS colour as 0–1 sRGB channels, resolved by the browser. */ function toRgb(color: string): Rgb { const probe = document.createElement("i"); probe.style.display = "none"; probe.style.color = color; document.body.appendChild(probe); const [r = 0, g = 0, b = 0] = (getComputedStyle(probe).color.match(/[\d.]+/g) ?? []).map(Number); probe.remove(); return [r / 255, g / 255, b / 255]; } /** The Modus theme tokens, re-read whenever the mode or palette on changes. */ function useTheme() { const [theme, setTheme] = useState(null); useEffect(() => { const root = document.documentElement; const read = () => { const style = getComputedStyle(root); setTheme( Object.fromEntries( TOKENS.map((t) => [t, toRgb(`hsl(${style.getPropertyValue(`--${t}`).trim()})`)]), ) as Theme, ); }; read(); const observer = new MutationObserver(read); observer.observe(root, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return theme; } const mix = (a: Rgb, b: Rgb, t: number): Rgb => [ a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, ]; export function LatticeDrift({ speed = 0.7, pointSize = 0.11, opacity = 0.75, maskStart = 0.05, maskSolid = 0.35, color, accentColor, backgroundColor, className, }: LatticeDriftProps) { const prefersReducedMotion = useReducedMotion(); const theme = useTheme(); const hostRef = useRef(null); const canvasRef = useRef(null); const options = useRef({ speed, pointSize, opacity }); options.current = { speed, pointSize, opacity }; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; if (!host || !canvas || !theme) return; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000); // Looking down onto the dome's crown rather than across its rim. camera.position.set(0, 14, 24); camera.lookAt(0, -2, 0); const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // The original ran on three r128 (no colour management): colours reach the screen untouched. renderer.outputColorSpace = THREE.LinearSRGBColorSpace; const ink = color ? toRgb(color) : theme.foreground; const accent = accentColor ? toRgb(accentColor) : theme.primary; const geometry = new THREE.BufferGeometry(); const count = 15000; const positions = new Float32Array(count * 3); const tints = new Float32Array(count * 3); const radius = 25; for (let index = 0; index < count; index += 1) { const u = Math.random(); Math.random(); // The original sampled an unused v here. const theta = u * 2 * Math.PI; const phi = Math.acos(Math.random() * 0.8 + 0.2); positions[index * 3] = radius * Math.sin(phi) * Math.cos(theta); positions[index * 3 + 1] = radius * Math.cos(phi) - 20; positions[index * 3 + 2] = radius * Math.sin(phi) * Math.sin(theta); // Points ink in the foreground, with a seeded scatter of accent points. tints.set( Math.random() < 0.14 ? accent : mix(ink, theme["muted-foreground"], Math.random() * 0.5), index * 3, ); } geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(tints, 3)); const material = new THREE.PointsMaterial({ size: 0.08, vertexColors: true, transparent: true, opacity: 0.4, depthWrite: false, }); const particles = new THREE.Points(geometry, material); scene.add(particles); const render = (advance: boolean) => { const o = options.current; if (advance) { particles.rotation.y += 0.0008 * o.speed; particles.rotation.z += 0.0002 * o.speed; } material.size = o.pointSize; material.opacity = o.opacity; renderer.render(scene, camera); }; const resize = () => { const { width, height } = host.getBoundingClientRect(); camera.aspect = width / Math.max(1, height); camera.updateProjectionMatrix(); renderer.setSize(width, height, false); render(false); }; let frame = 0; let visible = true; const tick = () => { render(true); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const start = () => { if (!prefersReducedMotion && visible && !frame) frame = requestAnimationFrame(tick); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) start(); else if (frame) { cancelAnimationFrame(frame); frame = 0; } }); const onVisibility = () => !document.hidden && start(); document.addEventListener("visibilitychange", onVisibility); resizeObserver.observe(host); intersection.observe(host); resize(); start(); return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); geometry.dispose(); material.dispose(); renderer.dispose(); }; }, [prefersReducedMotion, theme, color, accentColor]); const mask = `linear-gradient(to bottom, transparent ${maskStart * 100}%, black ${maskSolid * 100}%, black 100%)`; return (
); } ``` --- # Star Tunnel > A WebGL flight down a corridor of letters, streaks, keycaps or rings, coloured from a ramp of the theme primary and foreground. Install: `npx @modus-ui/cli add star-tunnel` A WebGL flight down a corridor of letters, streaks, keycaps or rings, coloured from a ramp of the theme primary and foreground. It fills its positioned parent, follows the active theme and palette in light and dark mode, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add star-tunnel ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/star-tunnel.tsx`. 3. Paste the code. ## Usage ```tsx import { StarTunnel } from "@/components/ui/star-tunnel"; export default function Example() { return (

Content over the background

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :---------------------------------------------------- | :----------------------------------- | :------------------------------------------------ | | `variant` | `"streaks" \| "letters" \| "keycaps" \| "hyperspace"` | `"letters"` | Which corridor to fly through. | | `speed` | `number` | `12` | Distance travelled per frame. | | `streakOpacity` | `number` | `0.6` | Opacity of the streaks (and keycap sparks). | | `tileOpacity` | `number` | `0.9` | Opacity of the tiles, letters, keycaps or tunnel. | | `fov` | `number` | `62` | Camera field of view in degrees. | | `brightness` | `number` | `1` | CSS brightness filter. | | `hue` | `number` | `0` | Hue rotation in degrees. | | `saturation` | `number` | `1` | CSS saturation filter. | | `colors` | `string[]` | ramp of theme primary and foreground | Streak colours. | | `backgroundColor` | `string` | theme background | Scene and fog colour. | ## Source `components/ui/star-tunnel.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Warp Field (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import * as THREE from "three"; import { cn } from "@/lib/utils"; type StarTunnelVariant = "streaks" | "letters" | "keycaps" | "hyperspace"; interface StarTunnelProps { variant?: StarTunnelVariant; speed?: number; streakOpacity?: number; tileOpacity?: number; fov?: number; brightness?: number; hue?: number; saturation?: number; /** CSS colours for the streaks. Default to a ramp of the theme primary and foreground. */ colors?: string[]; /** Defaults to the theme background. */ backgroundColor?: string; className?: string; } type Rgb = [number, number, number]; const TOKENS = [ "background", "foreground", "primary", "muted-foreground", "border", "card", ] as const; type Theme = Record<(typeof TOKENS)[number], Rgb>; /** Any CSS colour as 0–1 sRGB channels, resolved by the browser. */ function toRgb(color: string): Rgb { const probe = document.createElement("i"); probe.style.display = "none"; probe.style.color = color; document.body.appendChild(probe); const [r = 0, g = 0, b = 0] = (getComputedStyle(probe).color.match(/[\d.]+/g) ?? []).map(Number); probe.remove(); return [r / 255, g / 255, b / 255]; } /** The Modus theme tokens, re-read whenever the mode or palette on changes. */ function useTheme() { const [theme, setTheme] = useState(null); useEffect(() => { const root = document.documentElement; const read = () => { const style = getComputedStyle(root); setTheme( Object.fromEntries( TOKENS.map((t) => [t, toRgb(`hsl(${style.getPropertyValue(`--${t}`).trim()})`)]), ) as Theme, ); }; read(); const observer = new MutationObserver(read); observer.observe(root, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return theme; } const mix = (a: Rgb, b: Rgb, t: number): Rgb => [ a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, ]; const css = (c: Rgb, alpha = 1) => `rgb(${c.map((v) => Math.round(v * 255)).join(" ")} / ${alpha})`; // Output is linear (no colour management), so sRGB channels reach the screen untouched. const color3 = (c: Rgb) => new THREE.Color(c[0], c[1], c[2]); /** The scene's colours, all derived from the Modus theme tokens. */ type Inks = { ink: Rgb; accent: Rgb; soft: Rgb; ground: Rgb; card: Rgb; border: Rgb; font: string }; const RECYCLE_Z = 200; const CAP_RECYCLE_Z = 110; const CAP_RESET_Z = -1200; /* the glyph and keycap corridors are shorter than the streak corridor: a rigid wrap over the full 2000 units arrives in waves at these object counts, and the solid ones also have to turn back before they can swallow the lens */ const LETTER_RECYCLE_Z = 140; const LETTER_RESET_Z = -1300; const RESET_Z = -1800; const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const SPEED_SCALE: Record = { streaks: 1, letters: 0.5, keycaps: 0.7, hyperspace: 2.4, }; type Layer = { update?: (step: number, time: number) => void; setOpacity?: (streakOpacity: number, tileOpacity: number) => void; dispose: () => void; }; type StreakSettings = { count: number; radiusMin: number; radiusSpread: number; lengthMin: number; lengthSpread: number; palette: Rgb[]; opacityScale: number; }; function createStreakLayer(group: THREE.Group, settings: StreakSettings, opacity: number): Layer { const geometry = new THREE.BufferGeometry(); const positions = new Float32Array(settings.count * 6); const colors = new Float32Array(settings.count * 6); const palette = settings.palette.map(color3); for (let index = 0; index < settings.count; index += 1) { const angle = Math.random() * Math.PI * 2; const radius = Math.random() * settings.radiusSpread + settings.radiusMin; const x = Math.cos(angle) * radius; const y = Math.sin(angle) * radius; const z = (Math.random() - 0.5) * 2000; const length = Math.random() * settings.lengthSpread + settings.lengthMin; positions[index * 6] = x; positions[index * 6 + 1] = y; positions[index * 6 + 2] = z; positions[index * 6 + 3] = x; positions[index * 6 + 4] = y; positions[index * 6 + 5] = z + length; const color = palette[Math.floor(Math.random() * palette.length)]; colors[index * 6] = color.r; colors[index * 6 + 1] = color.g; colors[index * 6 + 2] = color.b; colors[index * 6 + 3] = color.r; colors[index * 6 + 4] = color.g; colors[index * 6 + 5] = color.b; } geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); const material = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: opacity * settings.opacityScale, }); const streaks = new THREE.LineSegments(geometry, material); group.add(streaks); const attribute = geometry.attributes.position as unknown as { array: Float32Array; needsUpdate: boolean; }; return { update(step) { for (let index = 0; index < settings.count; index += 1) { positions[index * 6 + 2] += step; positions[index * 6 + 5] += step; if (positions[index * 6 + 2] > RECYCLE_Z) { const length = positions[index * 6 + 5] - positions[index * 6 + 2]; positions[index * 6 + 2] = RESET_Z; positions[index * 6 + 5] = RESET_Z + length; } } attribute.needsUpdate = true; }, setOpacity(streakOpacity) { const scaled = streakOpacity * settings.opacityScale; if (material.opacity !== scaled) material.opacity = scaled; }, dispose() { geometry.dispose(); material.dispose(); }, }; } /* 40 slim panes flying past on the rails */ function createTileLayer(group: THREE.Group, opacity: number, inks: Inks): Layer { const geometry = new THREE.PlaneGeometry(8, 20); const template = new THREE.MeshBasicMaterial({ transparent: true, opacity, side: THREE.DoubleSide, }); const tiles: THREE.Mesh[] = []; let lastOpacity = opacity; for (let index = 0; index < 40; index += 1) { const material = template.clone(); material.color = color3( Math.random() > 0.6 ? inks.accent : Math.random() > 0.5 ? inks.soft : inks.ink, ); const mesh = new THREE.Mesh(geometry, material); const angle = Math.random() * Math.PI * 2; const radius = Math.random() * 400 + 100; mesh.position.x = Math.cos(angle) * radius; mesh.position.y = Math.sin(angle) * radius; mesh.position.z = (Math.random() - 0.5) * 2000; mesh.lookAt(0, 0, mesh.position.z + 100); const scale = Math.random() * 1.5 + 0.5; mesh.scale.set(scale, scale, scale); group.add(mesh); tiles.push(mesh); } return { update(step) { tiles.forEach((tile) => { tile.position.z += step; if (tile.position.z > RECYCLE_Z) tile.position.z = RESET_Z; }); }, setOpacity(_streakOpacity, tileOpacity) { if (lastOpacity === tileOpacity) return; tiles.forEach((tile) => { (tile.material as THREE.MeshBasicMaterial).opacity = tileOpacity; }); lastOpacity = tileOpacity; }, dispose() { geometry.dispose(); template.dispose(); tiles.forEach((tile) => (tile.material as THREE.MeshBasicMaterial).dispose()); }, }; } type GlyphAtlas = { texture: THREE.CanvasTexture; columns: number; rows: number }; function createGlyphAtlas(font: string): GlyphAtlas { const columns = 6; const rows = 6; const cell = 128; const canvas = document.createElement("canvas"); canvas.width = columns * cell; canvas.height = rows * cell; const context = canvas.getContext("2d"); if (context) { context.fillStyle = "white"; context.font = `500 ${Math.round(cell * 0.68)}px ${font}`; context.textAlign = "center"; context.textBaseline = "middle"; GLYPHS.split("").forEach((glyph, index) => { const column = index % columns; const row = Math.floor(index / columns); context.fillText(glyph, column * cell + cell / 2, row * cell + cell * 0.54); }); } const texture = new THREE.CanvasTexture(canvas); texture.minFilter = THREE.LinearFilter; return { texture, columns, rows }; } /* one unit plane per glyph, with its uv window pinned to that atlas cell, so a whole alphabet renders from a single texture and a handful of materials */ function createGlyphGeometries(atlas: GlyphAtlas): THREE.PlaneGeometry[] { return GLYPHS.split("").map((_, index) => { const geometry = new THREE.PlaneGeometry(1, 1); const column = index % atlas.columns; const row = Math.floor(index / atlas.columns); const originU = column / atlas.columns; const originV = 1 - (row + 1) / atlas.rows; const uv = geometry.attributes.uv as THREE.BufferAttribute; for (let vertex = 0; vertex < uv.count; vertex += 1) { uv.setXY( vertex, originU + uv.getX(vertex) / atlas.columns, originV + uv.getY(vertex) / atlas.rows, ); } uv.needsUpdate = true; return geometry; }); } type Tumbler = { mesh: THREE.Mesh; spin: number; swayX: number; swayY: number; phase: number; drift: number; radius: number; }; function createLetterLayer(group: THREE.Group, opacity: number, inks: Inks): Layer { const atlas = createGlyphAtlas(inks.font); const geometries = createGlyphGeometries(atlas); const tints = [inks.ink, inks.ink, inks.accent, inks.soft].map( (color) => new THREE.MeshBasicMaterial({ map: atlas.texture, color: color3(color), transparent: true, opacity, depthWrite: false, side: THREE.DoubleSide, }), ); const letters: Tumbler[] = []; let lastOpacity = opacity; for (let index = 0; index < 260; index += 1) { const mesh = new THREE.Mesh( geometries[Math.floor(Math.random() * geometries.length)], tints[Math.floor(Math.random() * tints.length)], ); const angle = Math.random() * Math.PI * 2; const radius = Math.random() * 430 + 60; mesh.position.set( Math.cos(angle) * radius, Math.sin(angle) * radius, LETTER_RESET_Z + Math.random() * (LETTER_RECYCLE_Z - LETTER_RESET_Z), ); const scale = Math.random() * 30 + 24; mesh.scale.set(scale, scale, scale); group.add(mesh); letters.push({ mesh, spin: (Math.random() - 0.5) * 0.02, swayX: Math.random() * 0.5 + 0.2, swayY: Math.random() * 0.6 + 0.2, phase: Math.random() * Math.PI * 2, drift: Math.random() * 0.9 + 0.2, radius, }); } return { update(step, time) { letters.forEach((letter) => { const { mesh } = letter; mesh.position.z += step; /* letters wander across the corridor instead of running the rails, which is what separates them from the streaks flying past behind them */ const wander = time * letter.drift + letter.phase; mesh.position.x += Math.cos(wander) * letter.drift * 0.9; mesh.position.y += Math.sin(wander * 0.8) * letter.drift * 0.9; mesh.rotation.z += letter.spin; mesh.rotation.x = Math.sin(wander * 0.6) * letter.swayX; mesh.rotation.y = Math.cos(wander * 0.5) * letter.swayY; if (mesh.position.z > LETTER_RECYCLE_Z) { const angle = Math.random() * Math.PI * 2; const radius = Math.random() * 430 + 60; mesh.position.set(Math.cos(angle) * radius, Math.sin(angle) * radius, LETTER_RESET_Z); } }); }, setOpacity(_streakOpacity, tileOpacity) { if (lastOpacity === tileOpacity) return; tints.forEach((tint) => { tint.opacity = tileOpacity; }); lastOpacity = tileOpacity; }, dispose() { geometries.forEach((geometry) => geometry.dispose()); tints.forEach((tint) => tint.dispose()); atlas.texture.dispose(); }, }; } /* a box with its top face drawn in, the cheapest read of an injection-moulded cap */ function createKeycapGeometry(width: number, height: number, depth: number): THREE.BoxGeometry { const geometry = new THREE.BoxGeometry(width, height, depth); const position = geometry.attributes.position as THREE.BufferAttribute; for (let vertex = 0; vertex < position.count; vertex += 1) { if (position.getY(vertex) > 0) position.setXYZ( vertex, position.getX(vertex) * 0.78, position.getY(vertex), position.getZ(vertex) * 0.78, ); } position.needsUpdate = true; geometry.computeVertexNormals(); return geometry; } function createSparkTexture(): THREE.CanvasTexture { const size = 64; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const context = canvas.getContext("2d"); if (context) { const gradient = context.createRadialGradient( size / 2, size / 2, 0, size / 2, size / 2, size / 2, ); gradient.addColorStop(0, "white"); gradient.addColorStop(0.4, "rgb(255 255 255 / 0.5)"); gradient.addColorStop(1, "transparent"); context.fillStyle = gradient; context.fillRect(0, 0, size, size); } const texture = new THREE.CanvasTexture(canvas); return texture; } function createSparkLayer( group: THREE.Group, count: number, size: number, color: THREE.Color, opacity: number, radiusSpread: number, ) { const geometry = new THREE.BufferGeometry(); const positions = new Float32Array(count * 3); for (let index = 0; index < count; index += 1) { const angle = Math.random() * Math.PI * 2; const radius = Math.random() * radiusSpread + 20; positions[index * 3] = Math.cos(angle) * radius; positions[index * 3 + 1] = Math.sin(angle) * radius; positions[index * 3 + 2] = (Math.random() - 0.5) * 2000; } geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); const texture = createSparkTexture(); const material = new THREE.PointsMaterial({ map: texture, color, size, transparent: true, opacity, depthWrite: false, sizeAttenuation: true, }); const points = new THREE.Points(geometry, material); group.add(points); const attribute = geometry.attributes.position as unknown as { needsUpdate: boolean }; return { positions, count, material, update(step: number) { for (let index = 0; index < count; index += 1) { positions[index * 3 + 2] += step; if (positions[index * 3 + 2] > RECYCLE_Z) positions[index * 3 + 2] = RESET_Z; } attribute.needsUpdate = true; }, dispose() { geometry.dispose(); material.dispose(); texture.dispose(); }, }; } function createKeycapLayer( scene: THREE.Scene, group: THREE.Group, opacity: number, inks: Inks, ): Layer { const atlas = createGlyphAtlas(inks.font); const legendGeometries = createGlyphGeometries(atlas); const capGeometry = createKeycapGeometry(26, 14, 26); const bodies = [inks.card, mix(inks.card, inks.border, 0.6), inks.border].map( (color) => new THREE.MeshLambertMaterial({ color: color3(color), transparent: true, opacity, }), ); const legends = [inks.accent, inks.ink].map( (color) => new THREE.MeshBasicMaterial({ map: atlas.texture, color: color3(color), transparent: true, opacity, depthWrite: false, side: THREE.DoubleSide, }), ); const ambient = new THREE.AmbientLight(color3(inks.ground), 0.8 * Math.PI); const key = new THREE.DirectionalLight(new THREE.Color(1, 1, 1), 1.2 * Math.PI); key.position.set(0.4, 1, 0.7); const rim = new THREE.DirectionalLight(color3(inks.accent), 0.6 * Math.PI); rim.position.set(-0.7, -0.4, 0.5); const flash = new THREE.PointLight(color3(inks.accent), 0.8 * Math.PI, 900, 1); flash.position.set(0, 0, 140); scene.add(ambient, key, rim, flash); const caps: Tumbler[] = []; let lastOpacity = opacity; for (let index = 0; index < 95; index += 1) { const mesh = new THREE.Mesh(capGeometry, bodies[Math.floor(Math.random() * bodies.length)]); const legend = new THREE.Mesh( legendGeometries[Math.floor(Math.random() * legendGeometries.length)], legends[Math.floor(Math.random() * legends.length)], ); legend.scale.set(15, 15, 15); legend.position.y = 7.2; legend.rotation.x = -Math.PI / 2; mesh.add(legend); const angle = Math.random() * Math.PI * 2; const radius = Math.random() * 430 + 130; mesh.position.set( Math.cos(angle) * radius, Math.sin(angle) * radius, CAP_RESET_Z + Math.random() * (CAP_RECYCLE_Z - CAP_RESET_Z), ); mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI); const scale = Math.random() * 1.15 + 0.8; mesh.scale.set(scale, scale, scale); group.add(mesh); caps.push({ mesh, spin: (Math.random() - 0.5) * 0.03, swayX: (Math.random() - 0.5) * 0.026, swayY: (Math.random() - 0.5) * 0.03, phase: 0, drift: 0, radius, }); } const sparks = createSparkLayer(group, 750, 7, color3(inks.accent), opacity, 620); return { update(step) { caps.forEach((cap) => { cap.mesh.position.z += step; cap.mesh.rotation.x += cap.swayX; cap.mesh.rotation.y += cap.swayY; cap.mesh.rotation.z += cap.spin; /* a cap is a solid volume, so it wraps before it can swallow the lens */ if (cap.mesh.position.z > CAP_RECYCLE_Z) { const angle = Math.random() * Math.PI * 2; const radius = Math.random() * 430 + 130; cap.mesh.position.set(Math.cos(angle) * radius, Math.sin(angle) * radius, CAP_RESET_Z); } }); sparks.update(step * 1.35); }, setOpacity(streakOpacity, tileOpacity) { if (sparks.material.opacity !== streakOpacity) sparks.material.opacity = streakOpacity; if (lastOpacity === tileOpacity) return; bodies.forEach((body) => { body.opacity = tileOpacity; }); legends.forEach((legend) => { legend.opacity = tileOpacity; }); lastOpacity = tileOpacity; }, dispose() { capGeometry.dispose(); legendGeometries.forEach((geometry) => geometry.dispose()); bodies.forEach((body) => body.dispose()); legends.forEach((legend) => legend.dispose()); atlas.texture.dispose(); sparks.dispose(); scene.remove(ambient, key, rim, flash); }, }; } function createTunnelTexture(ink: Rgb): THREE.CanvasTexture { const size = 512; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const context = canvas.getContext("2d"); if (context) { for (let index = 0; index < 240; index += 1) { const x = Math.random() * size; const width = Math.random() * 3 + 0.6; const height = Math.random() * 320 + 90; const top = Math.random() * size; const alpha = (Math.random() * 0.45 + 0.08).toFixed(3); /* drawn once above and once below the seam so the scrolling wrap is invisible */ for (const offset of [-size, 0, size]) { const gradient = context.createLinearGradient(0, top + offset, 0, top + offset + height); gradient.addColorStop(0, css(ink, 0)); gradient.addColorStop(0.5, css(ink, Number(alpha))); gradient.addColorStop(1, css(ink, 0)); context.fillStyle = gradient; context.fillRect(x, top + offset, width, height); } } } const texture = new THREE.CanvasTexture(canvas); texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.repeat.set(4, 2); return texture; } function createGlowTexture(inks: Inks): THREE.CanvasTexture { const size = 256; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const context = canvas.getContext("2d"); if (context) { const gradient = context.createRadialGradient( size / 2, size / 2, 0, size / 2, size / 2, size / 2, ); gradient.addColorStop(0, css(inks.accent, 0.9)); gradient.addColorStop(0.18, css(inks.accent, 0.45)); gradient.addColorStop(0.45, css(inks.soft, 0.14)); gradient.addColorStop(1, css(inks.ground, 0)); context.fillStyle = gradient; context.fillRect(0, 0, size, size); } const texture = new THREE.CanvasTexture(canvas); return texture; } function createHyperspaceLayer(group: THREE.Group, opacity: number, inks: Inks): Layer { const tunnelTexture = createTunnelTexture(inks.ink); const tunnelGeometry = new THREE.CylinderGeometry(900, 240, 3000, 64, 1, true); tunnelGeometry.rotateX(Math.PI / 2); const tunnelMaterial = new THREE.MeshBasicMaterial({ map: tunnelTexture, side: THREE.BackSide, transparent: true, opacity: opacity * 0.6, depthWrite: false, }); const tunnel = new THREE.Mesh(tunnelGeometry, tunnelMaterial); tunnel.position.z = -1400; group.add(tunnel); const glowTexture = createGlowTexture(inks); const glowMaterial = new THREE.SpriteMaterial({ map: glowTexture, transparent: true, opacity, depthWrite: false, }); const glow = new THREE.Sprite(glowMaterial); glow.position.z = -900; glow.scale.set(760, 760, 1); group.add(glow); let lastOpacity = opacity; return { update(step, time) { /* the walls run with the streaks, and a slow roll keeps the jump from reading as a still image once the streak rails line up */ tunnelTexture.offset.y -= step * 0.0016; tunnel.rotation.z += 0.0016; const pulse = 1 + Math.sin(time * 1.6) * 0.06; glow.scale.set(760 * pulse, 760 * pulse, 1); }, setOpacity(_streakOpacity, tileOpacity) { if (lastOpacity === tileOpacity) return; tunnelMaterial.opacity = tileOpacity * 0.6; glowMaterial.opacity = tileOpacity; lastOpacity = tileOpacity; }, dispose() { tunnelGeometry.dispose(); tunnelMaterial.dispose(); tunnelTexture.dispose(); glowMaterial.dispose(); glowTexture.dispose(); }, }; } const STREAK_SETTINGS: Record> = { streaks: { count: 400, radiusMin: 20, radiusSpread: 800, lengthMin: 50, lengthSpread: 150, opacityScale: 1, }, letters: { count: 260, radiusMin: 20, radiusSpread: 800, lengthMin: 40, lengthSpread: 120, opacityScale: 1, }, keycaps: { count: 220, radiusMin: 20, radiusSpread: 800, lengthMin: 40, lengthSpread: 140, opacityScale: 1, }, hyperspace: { count: 1200, radiusMin: 6, radiusSpread: 760, lengthMin: 170, lengthSpread: 420, opacityScale: 1.45, }, }; export function StarTunnel({ variant = "letters", speed = 12, streakOpacity = 0.6, tileOpacity = 0.9, fov = 62, brightness = 1, hue = 0, saturation = 1, colors, backgroundColor, className, }: StarTunnelProps) { const prefersReducedMotion = useReducedMotion(); const theme = useTheme(); const hostRef = useRef(null); const canvasRef = useRef(null); const options = useRef({ speed, streakOpacity, tileOpacity, fov }); options.current = { speed, streakOpacity, tileOpacity, fov }; const paletteKey = colors?.join(",") ?? ""; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; if (!host || !canvas || !theme) return; const ground = backgroundColor ? toRgb(backgroundColor) : theme.background; const inks: Inks = { ink: theme.foreground, accent: theme.primary, soft: mix(theme.primary, theme["muted-foreground"], 0.5), ground, card: theme.card, border: theme.border, font: getComputedStyle(host).fontFamily, }; const startOptions = options.current; const scene = new THREE.Scene(); scene.background = color3(ground); scene.fog = new THREE.FogExp2(color3(ground), 0.0012); const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 2000); camera.position.z = 0; const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: "high-performance", }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.outputColorSpace = THREE.LinearSRGBColorSpace; const group = new THREE.Group(); scene.add(group); const settings = STREAK_SETTINGS[variant]; const palette = paletteKey ? paletteKey.split(",").map(toRgb) : [inks.ink, inks.accent, inks.soft, mix(inks.ink, inks.ground, 0.4)]; const layers: Layer[] = [ createStreakLayer(group, { ...settings, palette }, startOptions.streakOpacity), ]; if (variant === "streaks") layers.push(createTileLayer(group, startOptions.tileOpacity, inks)); if (variant === "letters") layers.push(createLetterLayer(group, startOptions.tileOpacity, inks)); if (variant === "keycaps") layers.push(createKeycapLayer(scene, group, startOptions.tileOpacity, inks)); if (variant === "hyperspace") layers.push(createHyperspaceLayer(group, startOptions.tileOpacity, inks)); let elapsed = 0; const render = (advance: boolean) => { const o = options.current; if (camera.fov !== o.fov) { camera.fov = o.fov; camera.updateProjectionMatrix(); } if (advance) elapsed += 1 / 60; const step = advance ? o.speed * SPEED_SCALE[variant] : 0; layers.forEach((layer) => { layer.setOpacity?.(o.streakOpacity, o.tileOpacity); if (advance) layer.update?.(step, elapsed); }); renderer.render(scene, camera); }; const resize = () => { const { width, height } = host.getBoundingClientRect(); camera.aspect = width / Math.max(1, height); camera.updateProjectionMatrix(); renderer.setSize(width, height, false); render(false); }; let frame = 0; let visible = true; const tick = () => { render(true); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const start = () => { if (!prefersReducedMotion && visible && !frame) frame = requestAnimationFrame(tick); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) start(); else if (frame) { cancelAnimationFrame(frame); frame = 0; } }); const onVisibility = () => !document.hidden && start(); document.addEventListener("visibilitychange", onVisibility); resizeObserver.observe(host); intersection.observe(host); resize(); start(); return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); layers.forEach((layer) => layer.dispose()); renderer.dispose(); }; }, [prefersReducedMotion, variant, paletteKey, backgroundColor, theme]); return (
); } ``` --- # Plate Wheel > A tilted WebGL wheel of curved, muted image plates turning over a ruled sheet in the theme colours. Install: `npx @modus-ui/cli add plate-wheel` Sixteen curved image plates wound around a tilted cylinder, turning slowly and floating up and down over a ruled grid drawn from the theme background and foreground tokens. Photos are desaturated by default for a quiet editorial tone. It fills its positioned parent, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add plate-wheel ``` ### Manual 1. Install `three`. 2. Copy the code from the component tab. 3. Create a file at `components/ui/plate-wheel.tsx`. 4. Paste the code. ## Usage ```tsx import { PlateWheel } from "@/components/ui/plate-wheel"; export default function Example() { return (

Content over the effect

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :--------- | :-------------------------------- | :------------------------------------------- | | `images` | `string[]` | five sample photos | Image URLs, repeated across the 16 panels. | | `speed` | `number` | `1` | Rotation speed, clamped to 0–3. | | `scale` | `number` | `1` | Scale of the helix, clamped to 0.7–1.35. | | `opacity` | `number` | `1` | Canvas opacity, clamped to 0.05–1. | | `hue` | `number` | `0` | Hue rotation in degrees, clamped to ±180. | | `saturation` | `number` | `0.55` | CSS saturation filter, clamped to 0–2. | | `brightness` | `number` | `1` | CSS brightness filter, clamped to 0.35–1.65. | | `backgroundColor` | `string` | `"hsl(var(--background))"` | Background colour. | | `dotColor` | `string` | `"hsl(var(--foreground) / 0.08)"` | Colour of the ruled background grid. | ## Source `components/ui/plate-wheel.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Gallery (MIT, © 2026 Meng To). import { useEffect, useRef } from "react"; import { useReducedMotion } from "framer-motion"; import * as THREE from "three"; import { cn } from "@/lib/utils"; export interface PlateWheelProps { images?: string[]; speed?: number; scale?: number; opacity?: number; hue?: number; saturation?: number; brightness?: number; backgroundColor?: string; dotColor?: string; className?: string; } const DEFAULT_IMAGES = [ "https://images.unsplash.com/photo-1755331039789-7e5680e26e8f?q=80&w=774&auto=format&fit=crop", "https://images.unsplash.com/photo-1755569309049-98410b94f66d?q=80&w=772&auto=format&fit=crop", "https://images.unsplash.com/photo-1755497595318-7e5e3523854f?q=80&w=774&auto=format&fit=crop", "https://images.unsplash.com/photo-1755353985163-c2a0fe5ac3d8?q=80&w=774&auto=format&fit=crop", "https://images.unsplash.com/photo-1745965976680-d00be7dc0377?q=80&w=774&auto=format&fit=crop", ]; const clamp = (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value)); export function PlateWheel({ images = DEFAULT_IMAGES, speed = 1, scale = 1, opacity = 1, hue = 0, saturation = 0.55, brightness = 1, backgroundColor = "hsl(var(--background))", dotColor = "hsl(var(--foreground) / 0.08)", className, }: PlateWheelProps) { const prefersReducedMotion = useReducedMotion(); const hostRef = useRef(null); const canvasRef = useRef(null); const settings = useRef({ speed, scale }); settings.current = { speed, scale }; const imageKey = images.join("\n"); useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; const urls = imageKey.split("\n").filter(Boolean); if (!host || !canvas || urls.length === 0) return; const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); renderer.setClearColor(0x000000, 0); renderer.outputColorSpace = THREE.SRGBColorSpace; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100); camera.position.set(0, 3.2, 17); camera.lookAt(0, 0, 0); const gallery = new THREE.Group(); gallery.rotation.z = -0.12; scene.add(gallery); const geometry = new THREE.CylinderGeometry(5, 5, 1.8, 64, 1, true, 0, Math.PI * 0.4); const loader = new THREE.TextureLoader(); loader.setCrossOrigin("anonymous"); let disposed = false; let frame = 0; let elapsed = 0; let previousTime = 0; let hostVisible = true; const render = (time = performance.now()) => { const safeSpeed = clamp(settings.current.speed, 0, 3); const safeScale = clamp(settings.current.scale, 0.7, 1.35); if (previousTime) elapsed += Math.min((time - previousTime) / 1000, 0.05) * safeSpeed; previousTime = time; gallery.rotation.y = elapsed * 0.18; gallery.position.y = Math.sin(elapsed) * 1.5; gallery.scale.setScalar(safeScale); renderer.render(scene, camera); }; const textures = urls.map((url) => { const texture = loader.load(url, () => { if (disposed) { texture.dispose(); return; } renderer.render(scene, camera); }); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy()); return texture; }); const materials = Array.from( { length: 16 }, (_, index) => new THREE.MeshBasicMaterial({ map: textures[index % textures.length], opacity: 0.85, side: THREE.DoubleSide, toneMapped: false, transparent: true, }), ); materials.forEach((material, index) => { const panel = new THREE.Mesh(geometry, material); panel.position.y = (index - 8) * 2.4; panel.rotation.y = (index / 16) * Math.PI * 4; gallery.add(panel); }); const tick = (time: number) => { if (disposed || !hostVisible || document.hidden) { frame = 0; previousTime = 0; return; } render(time); frame = requestAnimationFrame(tick); }; const start = () => { if (prefersReducedMotion) { render(0); return; } if (!frame && hostVisible && !document.hidden) frame = requestAnimationFrame(tick); }; const stop = () => { if (frame) cancelAnimationFrame(frame); frame = 0; previousTime = 0; }; const resize = () => { const bounds = host.getBoundingClientRect(); const width = Math.max(1, Math.round(bounds.width)); const height = Math.max(1, Math.round(bounds.height)); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); render(); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver(([entry]) => { hostVisible = entry?.isIntersecting ?? true; if (hostVisible) start(); else stop(); }); const onVisibility = () => (document.hidden ? stop() : start()); resizeObserver.observe(host); intersection.observe(host); document.addEventListener("visibilitychange", onVisibility); resize(); start(); return () => { disposed = true; stop(); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); gallery.clear(); geometry.dispose(); materials.forEach((material) => material.dispose()); textures.forEach((texture) => texture.dispose()); renderer.dispose(); }; }, [prefersReducedMotion, imageKey]); return (
); } ``` --- # Node Weave > A canvas weave of hollow knots and solid ink hubs, threaded by hairlines that tighten around the pointer. Install: `npx @modus-ui/cli add node-weave` Hollow knots drift across a 2D canvas on the page's own background, threaded together by hairlines whenever they come close; every sixth knot is a solid hub in the primary colour with a breathing ring, and its threads take on that colour. Colours come from the active theme, so the weave is ink on paper in light mode and follows the palette in dark mode. It fills its positioned parent, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add node-weave ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/node-weave.tsx`. 3. Paste the code. ## Usage ```tsx import { NodeWeave } from "@/components/ui/node-weave"; export default function Example() { return (

Content over the background

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :------- | :------------- | :-------------------------------------------------------------- | | `speed` | `number` | `1` | Drift and pulse speed multiplier (0 to 3). | | `size` | `number` | `1` | Knot radius multiplier. | | `length` | `number` | `1` | Link distance multiplier (base 160px, 0.35 to 2.5). | | `density` | `number` | `1` | Node count multiplier (85 nodes on wide hosts, 40 under 768px). | | `strokeWidth` | `number` | `0.75` | Link line width in pixels. | | `opacity` | `number` | `1` | Opacity of nodes and links (0.05 to 1). | | `hue` | `number` | `0` | Hue rotation in degrees (-180 to 180). | | `saturation` | `number` | `1` | Saturation multiplier (0 to 2). | | `brightness` | `number` | `1` | Brightness multiplier (0.35 to 1.65). | | `color` | `string` | `--primary` | Hub and hub-thread colour (theme token by default). | | `backgroundColor` | `string` | `--background` | Scene background (theme token by default). | ## Source `components/ui/node-weave.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Constellation Field (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; type Tokens = { primary: string; foreground: string; muted: string; background: string; card: string; border: string; dark: boolean; }; /** Reads the Modus theme tokens (bare HSL channels) as comma `hsl()` strings canvas and three.js both parse. */ function readTokens(): Tokens { const root = document.documentElement; const cs = getComputedStyle(root); const hsl = (name: string) => `hsl(${cs .getPropertyValue(name) .trim() .split(/[\s,]+/) .filter(Boolean) .join(", ")})`; return { primary: hsl("--primary"), foreground: hsl("--foreground"), muted: hsl("--muted-foreground"), background: hsl("--background"), card: hsl("--card"), border: hsl("--border"), dark: root.classList.contains("dark") || root.dataset.theme === "dark", }; } /** The current theme tokens, re-read whenever the mode or palette on changes. */ function useTokens() { const [tokens, setTokens] = useState(null); useEffect(() => { const update = () => setTokens(readTokens()); update(); const observer = new MutationObserver(update); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return tokens; } export interface NodeWeaveProps { speed?: number; size?: number; length?: number; density?: number; strokeWidth?: number; opacity?: number; hue?: number; saturation?: number; brightness?: number; /** Hub and link colour. Defaults to the theme's `--primary`. */ color?: string; /** Scene background. Defaults to the theme's `--background`. */ backgroundColor?: string; className?: string; } type Node = { x: number; y: number; vx: number; vy: number; radius: number; hub: boolean }; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); export function NodeWeave({ speed = 1, size = 1, length = 1, density = 1, strokeWidth = 0.75, opacity = 1, hue = 0, saturation = 1, brightness = 1, color, backgroundColor, className, }: NodeWeaveProps) { const prefersReducedMotion = useReducedMotion(); const hostRef = useRef(null); const canvasRef = useRef(null); const tokens = useTokens(); const ink = color ?? tokens?.primary ?? "transparent"; const thread = tokens?.muted ?? ink; const background = backgroundColor ?? "hsl(var(--background))"; const paper = backgroundColor ?? tokens?.background ?? "transparent"; const live = useRef({ speed, ink, thread, paper }); live.current = { speed: clamp(speed, 0, 3), ink, thread, paper }; const repaint = useRef<() => void>(() => {}); useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!host || !canvas || !ctx) return; const LINK = Math.round(160 * clamp(length, 0.35, 2.5)); const safeDensity = clamp(density, 0.25, 2.5); const nodeSize = clamp(size, 0.05, 200); const lineWidth = Number(Math.max(0.25, clamp(strokeWidth, 0.25, 8)).toFixed(2)); let width = 1; let height = 1; let nodes: Node[] = []; const pointer = { x: -1000, y: -1000 }; // Virtual clock so the node pulse follows `speed`, like the original's patched Date.now. let clock = 0; let last = performance.now(); const resize = () => { const rect = host.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); width = rect.width; height = rect.height; canvas.width = Math.max(1, Math.floor(width * dpr)); canvas.height = Math.max(1, Math.floor(height * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.imageSmoothingEnabled = false; }; const initNodes = () => { const max = width < 768 ? Math.max(8, Math.round(40 * safeDensity)) : Math.max(12, Math.round(85 * safeDensity)); nodes = []; for (let i = 0; i < max; i++) { nodes.push({ x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.3, vy: (Math.random() - 0.5) * 0.3, radius: (Math.random() * 1.6 + 1.4) * nodeSize, hub: i % 6 === 0, }); } }; const draw = (advance: boolean) => { const { speed: s, ink: fill, thread, paper } = live.current; const now = performance.now(); clock += (now - last) * (s || 1); last = now; ctx.clearRect(0, 0, width, height); ctx.lineCap = "butt"; ctx.lineJoin = "miter"; // Draw links first so nodes sit crisp on top. ctx.strokeStyle = thread; ctx.lineWidth = lineWidth; for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const d = Math.hypot(nodes[i].x - nodes[j].x, nodes[i].y - nodes[j].y); if (d < LINK) { const touchesHub = nodes[i].hub || nodes[j].hub; ctx.strokeStyle = touchesHub ? fill : thread; ctx.globalAlpha = (touchesHub ? 0.25 : 0.12) + (1 - d / LINK) * 0.45; ctx.beginPath(); ctx.moveTo(nodes[i].x, nodes[i].y); ctx.lineTo(nodes[j].x, nodes[j].y); ctx.stroke(); } } } nodes.forEach((node) => { if (advance) { node.x += node.vx * (s || 1); node.y += node.vy * (s || 1); if (node.x < 0 || node.x > width) node.vx *= -1; if (node.y < 0 || node.y > height) node.vy *= -1; // Gentle pointer gravity. if (Math.hypot(node.x - pointer.x, node.y - pointer.y) < 220) { node.x -= (node.x - pointer.x) * 0.005; node.y -= (node.y - pointer.y) * 0.005; } } // Hubs are solid ink with a breathing ring; the rest are hollow knots on the weave. const pulse = 0.5 + Math.sin(clock * 0.0012 + node.x * 0.05) * 0.5; if (node.hub) { ctx.fillStyle = fill; ctx.globalAlpha = 1; ctx.beginPath(); ctx.arc(node.x, node.y, node.radius * 1.2, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = fill; ctx.globalAlpha = 0.15 + pulse * 0.35; ctx.beginPath(); ctx.arc(node.x, node.y, node.radius * (2.6 + pulse * 1.4), 0, Math.PI * 2); ctx.stroke(); } else { ctx.fillStyle = paper; ctx.strokeStyle = thread; ctx.globalAlpha = 0.9; ctx.beginPath(); ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } }); ctx.globalAlpha = 1; }; const onPointerMove = (event: PointerEvent) => { const rect = host.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const inside = x >= 0 && y >= 0 && x <= rect.width && y <= rect.height; pointer.x = inside ? x : -1000; pointer.y = inside ? y : -1000; }; const onPointerLeave = () => { pointer.x = -1000; pointer.y = -1000; }; let frame = 0; let visible = true; const tick = () => { draw(true); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const start = () => { if (prefersReducedMotion || frame || !visible || document.hidden) return; last = performance.now(); frame = requestAnimationFrame(tick); }; const stop = () => { if (frame) cancelAnimationFrame(frame); frame = 0; }; const resizeObserver = new ResizeObserver(() => { resize(); initNodes(); draw(false); }); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) start(); else stop(); }); const onVisibility = () => (document.hidden ? stop() : start()); repaint.current = () => draw(false); resize(); initNodes(); draw(false); resizeObserver.observe(host); intersection.observe(host); window.addEventListener("pointermove", onPointerMove); document.documentElement.addEventListener("pointerleave", onPointerLeave); document.addEventListener("visibilitychange", onVisibility); start(); return () => { stop(); resizeObserver.disconnect(); intersection.disconnect(); window.removeEventListener("pointermove", onPointerMove); document.documentElement.removeEventListener("pointerleave", onPointerLeave); document.removeEventListener("visibilitychange", onVisibility); }; }, [prefersReducedMotion, size, length, density, strokeWidth]); // Repaint the still frame when the theme changes under it. useEffect(() => { repaint.current(); }, [ink, thread, paper]); const safeHue = clamp(hue, -180, 180); const safeSaturation = clamp(saturation, 0, 2); const safeBrightness = clamp(brightness, 0.35, 1.65); const filter = safeHue === 0 && safeSaturation === 1 && safeBrightness === 1 ? undefined : `hue-rotate(${safeHue}deg) saturate(${safeSaturation}) brightness(${safeBrightness})`; return (
); } ``` --- # Aperture Bloom > A centred, noise-warped pair of WebGL rings inked in the theme colours that leans toward the pointer. Install: `npx @modus-ui/cli add aperture-bloom` A full-bleed Three.js shader draws a tight aperture of two noise-warped rings at the centre of the frame. In light mode they are inked on `--background` in `--primary` with a `--muted-foreground` fringe; in dark mode they glow additively, a `--foreground` core over a `--primary` fringe. It follows palette and mode changes live, and drifts slightly toward the pointer. It fills its positioned parent, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add aperture-bloom ``` ### Manual 1. Install `three`. 2. Copy the code from the component tab. 3. Create a file at `components/ui/aperture-bloom.tsx`. 4. Paste the code. ## Usage ```tsx import { ApertureBloom } from "@/components/ui/aperture-bloom"; export default function Example() { return (

Content over the background

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :------- | :------------------- | :------------------------------------------ | | `speed` | `number` | `1` | Animation speed multiplier (0 to 3). | | `size` | `number` | `1` | Ring thickness multiplier. | | `length` | `number` | `1` | Ring radius multiplier (0.35 to 2.5). | | `opacity` | `number` | `1` | Opacity of the glow (0.05 to 1). | | `hue` | `number` | `0` | Hue rotation in degrees (-180 to 180). | | `saturation` | `number` | `1` | Saturation multiplier (0 to 2). | | `brightness` | `number` | `1` | Brightness multiplier (0.35 to 1.65). | | `coreColor` | `string` | theme tokens | Colour of the ring's bright core. | | `fringeColor` | `string` | theme tokens | Colour of the soft fringe and ambient wash. | | `backgroundColor` | `string` | theme `--background` | Scene background behind the glow. | ## Source `components/ui/aperture-bloom.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Portal Field (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import * as THREE from "three"; import { cn } from "@/lib/utils"; export interface ApertureBloomProps { speed?: number; size?: number; length?: number; opacity?: number; hue?: number; saturation?: number; brightness?: number; coreColor?: string; fringeColor?: string; backgroundColor?: string; className?: string; } const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); // Shader colours are raw sRGB values, as in the original three r134 page (no colour management). const rawColor = (target: THREE.Color, value: string) => target.setStyle(value, THREE.LinearSRGBColorSpace); const readToken = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); /** A bare-HSL theme token as `hsl(h, s%, l%)`, the form three.js parses. */ const themeColor = (name: string) => `hsl(${readToken(name).split(/\s+/).join(", ")})`; const isLightTheme = () => Number.parseFloat(readToken("--background").split(/\s+/)[2] ?? "0") > 50; /** WebGL1 GLSL ES requires float literals (10.0), not ints (10). */ const glslFloat = (value: number, digits = 3) => { const fixed = Number(value).toFixed(digits); return fixed.includes(".") ? fixed : `${fixed}.0`; }; const vertexShader = ` varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 1.0); } `; const fragmentShader = (size: number, length: number) => ` uniform float u_time; uniform vec2 u_resolution; uniform vec2 u_mouse; uniform vec3 u_colorCore; uniform vec3 u_colorFringe; uniform float u_isLightMode; varying vec2 vUv; vec2 hash( vec2 p ) { p = vec2( dot(p,vec2(127.1,311.7)), dot(p,vec2(269.5,183.3)) ); return -1.0 + 2.0*fract(sin(p)*43758.5453123); } float noise( in vec2 p ) { const float K1 = 0.366025404; const float K2 = 0.211324865; vec2 i = floor( p + (p.x+p.y)*K1 ); vec2 a = p - i + (i.x+i.y)*K2; vec2 o = (a.x>a.y) ? vec2(1.0,0.0) : vec2(0.0,1.0); vec2 b = a - o + K2; vec2 c = a - 1.0 + 2.0*K2; vec3 h = max( 0.5-vec3(dot(a,a), dot(b,b), dot(c,c) ), 0.0 ); vec3 n = h*h*h*h*vec3( dot(a,hash(i+0.0)), dot(b,hash(i+o)), dot(c,hash(i+1.0))); return dot( n, vec3(70.0) ); } float sdArc(vec2 p, vec2 center, float radius, float width, float warp) { p.y += sin(p.x * 2.0 + u_time * 0.3) * warp; p.x += noise(p * 1.5 + u_time * 0.1) * (warp * 0.8); float d = length(p - center) - radius; return abs(d) - width; } void main() { vec2 uv = gl_FragCoord.xy / u_resolution.xy; vec2 st = uv; st.x *= u_resolution.x / u_resolution.y; vec2 mouseOffset = (u_mouse - 0.5) * 0.05; st += mouseOffset; // A smaller aperture centred in the frame, with a second ring set further out. vec2 center = vec2(0.5 * u_resolution.x / u_resolution.y, 0.5); float d1 = sdArc(st, center, ${glslFloat(0.3 * length, 3)}, ${glslFloat(0.012 * size, 4)}, 0.08); float d2 = sdArc(st, center, ${glslFloat(0.36 * length, 3)}, ${glslFloat(0.05 * size, 4)}, 0.14); float coreGlow = exp(-d1 * 30.0); float fringeGlow = exp(-d2 * 10.0); float wash = smoothstep(1.5, -0.5, length(st - center)) * 0.15; vec3 finalColor = vec3(0.0); finalColor += u_colorCore * coreGlow; finalColor += u_colorFringe * fringeGlow; finalColor += u_colorFringe * wash * (sin(u_time * 0.5) * 0.2 + 0.8); if(u_isLightMode > 0.5) { // On paper: ink the rings in the token colours instead of adding light. vec3 ink = mix(u_colorFringe, u_colorCore, clamp(coreGlow, 0.0, 1.0)); float inkAlpha = clamp(coreGlow * 0.9 + fringeGlow * 0.45 + wash * 0.5, 0.0, 0.85); gl_FragColor = vec4(ink * inkAlpha, inkAlpha); return; } float alpha = clamp(coreGlow + fringeGlow + wash, 0.0, 1.0); finalColor = vec3(1.0) - exp(-finalColor * 1.5); gl_FragColor = vec4(finalColor, alpha * 0.6); } `; export function ApertureBloom({ speed = 1, size = 1, length = 1, opacity = 1, hue = 0, saturation = 1, brightness = 1, coreColor, fringeColor, backgroundColor, className, }: ApertureBloomProps) { const prefersReducedMotion = useReducedMotion(); const hostRef = useRef(null); const live = useRef({ speed, coreColor, fringeColor }); live.current = { speed: clamp(speed, 0, 3), coreColor, fringeColor }; const [themeKey, setThemeKey] = useState(0); useEffect(() => { const host = hostRef.current; if (!host) return; const light = isLightTheme(); const colors = () => ({ core: live.current.coreColor ?? themeColor(light ? "--primary" : "--foreground"), fringe: live.current.fringeColor ?? themeColor(light ? "--muted-foreground" : "--primary"), }); const scene = new THREE.Scene(); const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1; const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.domElement.style.display = "block"; renderer.domElement.style.width = "100%"; renderer.domElement.style.height = "100%"; host.appendChild(renderer.domElement); const geometry = new THREE.PlaneGeometry(2, 2); const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader: fragmentShader(clamp(size, 0.05, 200), clamp(length, 0.35, 2.5)), uniforms: { u_time: { value: 0 }, u_resolution: { value: new THREE.Vector2(1, 1) }, u_mouse: { value: new THREE.Vector2(0.5, 0.5) }, u_colorCore: { value: rawColor(new THREE.Color(), colors().core) }, u_colorFringe: { value: rawColor(new THREE.Color(), colors().fringe) }, u_isLightMode: { value: light ? 1 : 0 }, }, transparent: true, premultipliedAlpha: light, blending: light ? THREE.NormalBlending : THREE.AdditiveBlending, }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); const targetMouse = new THREE.Vector2(0.5, 0.5); const onPointerMove = (event: PointerEvent) => { const rect = host.getBoundingClientRect(); targetMouse.x = (event.clientX - rect.left) / rect.width; targetMouse.y = 1 - (event.clientY - rect.top) / rect.height; }; const resize = () => { const rect = host.getBoundingClientRect(); const width = Math.max(1, rect.width); const height = Math.max(1, rect.height); renderer.setSize(width, height, false); // The original passes CSS pixels here while gl_FragCoord is in device pixels. material.uniforms.u_resolution.value.set(width, height); }; let time = 0; let last = performance.now(); const render = () => { const now = performance.now(); time += ((now - last) / 1000) * live.current.speed; last = now; material.uniforms.u_time.value = time; const { core, fringe } = colors(); rawColor(material.uniforms.u_colorCore.value, core); rawColor(material.uniforms.u_colorFringe.value, fringe); material.uniforms.u_mouse.value.lerp(targetMouse, 0.03); renderer.render(scene, camera); }; let frame = 0; let visible = true; const tick = () => { render(); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const start = () => { if (prefersReducedMotion || frame || !visible || document.hidden) return; last = performance.now(); frame = requestAnimationFrame(tick); }; const stop = () => { if (frame) cancelAnimationFrame(frame); frame = 0; }; const resizeObserver = new ResizeObserver(() => { resize(); render(); }); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) start(); else stop(); }); const onVisibility = () => (document.hidden ? stop() : start()); resize(); render(); resizeObserver.observe(host); intersection.observe(host); window.addEventListener("pointermove", onPointerMove); document.addEventListener("visibilitychange", onVisibility); start(); const themeObserver = new MutationObserver(() => setThemeKey((key) => key + 1)); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme", "data-palette"], }); return () => { stop(); themeObserver.disconnect(); resizeObserver.disconnect(); intersection.disconnect(); window.removeEventListener("pointermove", onPointerMove); document.removeEventListener("visibilitychange", onVisibility); geometry.dispose(); material.dispose(); renderer.dispose(); renderer.domElement.remove(); }; }, [prefersReducedMotion, size, length, themeKey]); const safeHue = clamp(hue, -180, 180); const safeSaturation = clamp(saturation, 0, 2); const safeBrightness = clamp(brightness, 0.35, 1.65); const filter = safeHue === 0 && safeSaturation === 1 && safeBrightness === 1 ? undefined : `hue-rotate(${safeHue}deg) saturate(${safeSaturation}) brightness(${safeBrightness})`; return (
); } ``` --- # Mosaic Crest > A swooping band of square tesserae that ripples with travelling waves, laid in the active theme colours. Install: `npx @modus-ui/cli add mosaic-crest` A 2D canvas lays square tesserae along a swooping band that dips low through the frame and rises to both corners, rippling with travelling waves. Edge tiles are muted ink; the crest of the band is laid in the primary colour, on the page's own background, so it reads as ink on paper in light mode and follows the active palette. Set a positive `arcDrop` to turn the band into an arch. It fills its positioned parent, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add mosaic-crest ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/mosaic-crest.tsx`. 3. Paste the code. ## Usage ```tsx import { MosaicCrest } from "@/components/ui/mosaic-crest"; export default function Example() { return (

Content over the background

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :------- | :------------- | :------------------------------------------------------------------------------------- | | `speed` | `number` | `1` | Wave speed multiplier. | | `pixelSize` | `number` | `12` | Size of each tile cell in pixels. | | `arcCenter` | `number` | `0.82` | Vertical position of the band at the centre, as a fraction of the height. | | `arcDrop` | `number` | `-0.72` | How far the band moves toward the sides, as a fraction of the height (negative rises). | | `thickness` | `number` | `0.24` | Thickness of the arc band, as a fraction of the height. | | `brightness` | `number` | `1` | Tile opacity multiplier. | | `hue` | `number` | `0` | Hue rotation in degrees. | | `saturation` | `number` | `1` | Saturation multiplier. | | `backgroundColor` | `string` | `--background` | Scene background (theme token by default). | ## Source `components/ui/mosaic-crest.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Data Pixel Arc (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; type Tokens = { primary: string; foreground: string; muted: string; background: string; card: string; border: string; dark: boolean; }; /** Reads the Modus theme tokens (bare HSL channels) as comma `hsl()` strings canvas and three.js both parse. */ function readTokens(): Tokens { const root = document.documentElement; const cs = getComputedStyle(root); const hsl = (name: string) => `hsl(${cs .getPropertyValue(name) .trim() .split(/[\s,]+/) .filter(Boolean) .join(", ")})`; return { primary: hsl("--primary"), foreground: hsl("--foreground"), muted: hsl("--muted-foreground"), background: hsl("--background"), card: hsl("--card"), border: hsl("--border"), dark: root.classList.contains("dark") || root.dataset.theme === "dark", }; } /** The current theme tokens, re-read whenever the mode or palette on changes. */ function useTokens() { const [tokens, setTokens] = useState(null); useEffect(() => { const update = () => setTokens(readTokens()); update(); const observer = new MutationObserver(update); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return tokens; } export interface MosaicCrestProps { speed?: number; pixelSize?: number; arcCenter?: number; arcDrop?: number; thickness?: number; brightness?: number; hue?: number; saturation?: number; /** Scene background. Defaults to the theme's `--background`. */ backgroundColor?: string; className?: string; } export function MosaicCrest({ speed = 1, pixelSize = 12, arcCenter = 0.82, arcDrop = -0.72, thickness = 0.24, brightness = 1, hue = 0, saturation = 1, backgroundColor, className, }: MosaicCrestProps) { const prefersReducedMotion = useReducedMotion(); const hostRef = useRef(null); const canvasRef = useRef(null); const tokens = useTokens(); const paper = backgroundColor ?? tokens?.background; const ink = tokens?.primary; const edge = tokens?.muted; const options = useRef({ speed, pixelSize, arcCenter, arcDrop, thickness, brightness, paper, ink, edge, }); const repaint = useRef<() => void>(() => {}); options.current = { speed, pixelSize, arcCenter, arcDrop, thickness, brightness, paper, ink, edge, }; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; const context = canvas?.getContext("2d", { alpha: false }); if (!host || !canvas || !context) return; let width = 1; let height = 1; let time = 0; const resize = () => { const bounds = host.getBoundingClientRect(); width = Math.max(1, bounds.width); height = Math.max(1, bounds.height); const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.round(width * pixelRatio); canvas.height = Math.round(height * pixelRatio); context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); }; const render = () => { const o = options.current; if (!o.paper || !o.ink || !o.edge) return; context.globalAlpha = 1; context.fillStyle = o.paper; context.fillRect(0, 0, width, height); const cols = Math.ceil(width / o.pixelSize); const rows = Math.ceil(height / o.pixelSize); const arcCenterY = height * o.arcCenter; const drop = height * o.arcDrop; const band = height * o.thickness; const gap = Math.max(1, Math.round(o.pixelSize * 0.18)); for (let x = 0; x < cols; x += 1) { for (let y = 0; y < rows; y += 1) { const px = x * o.pixelSize; const py = y * o.pixelSize; const nx = (px / width) * 2 - 1; const curveY = arcCenterY + Math.pow(Math.abs(nx), 1.8) * drop; let intensity = Math.max(0, 1 - Math.abs(py - curveY) / band); if (intensity <= 0.01) continue; const wave1 = Math.sin(nx * 4 - time * 1.5) * 0.1; const wave2 = Math.cos(py * 0.01 + time) * 0.1; intensity = Math.max(0, Math.min(1, intensity + wave1 + wave2)); intensity *= Math.max(0, 1 - Math.pow(Math.abs(nx), 2.5)); if (intensity <= 0.02) continue; const size = o.pixelSize - gap; // Muted tesserae make the band; the crest is laid in the primary colour on top. context.fillStyle = o.edge; context.globalAlpha = Math.min(1, Math.pow(intensity, 0.8) * 0.55 * o.brightness); context.fillRect(px, py, size, size); const core = Math.pow(intensity, 2.6); if (core > 0.04) { context.fillStyle = o.ink; context.globalAlpha = Math.min(1, core * o.brightness); context.fillRect(px, py, size, size); } } } context.globalAlpha = 1; time += 0.02 * o.speed; }; let frame = 0; let visible = true; const tick = () => { render(); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const start = () => { if (prefersReducedMotion || frame || !visible || document.hidden) return; frame = requestAnimationFrame(tick); }; const stop = () => { if (frame) cancelAnimationFrame(frame); frame = 0; }; const resizeObserver = new ResizeObserver(() => { resize(); render(); }); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) start(); else stop(); }); const onVisibility = () => (document.hidden ? stop() : start()); repaint.current = render; resize(); render(); resizeObserver.observe(host); intersection.observe(host); document.addEventListener("visibilitychange", onVisibility); start(); return () => { stop(); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); }; }, [prefersReducedMotion]); // Repaint the still frame when the theme changes under it. useEffect(() => { repaint.current(); }, [paper, ink, edge]); return (
); } ``` --- # Forecast Curve > A rising dotted curve whose dots swell and shimmer in crossing waves, inked in the theme accent. Install: `npx @modus-ui/cli add forecast-curve` A 2D canvas samples a curve that dips low in the middle and lifts toward both edges, like a chart line climbing out of a trough. Each dot on a sparse grid grows with its closeness to the curve and shimmers in crossing waves: cores are drawn in `--primary`, edges in `--muted-foreground`, on `--background`. In dark mode the dots blend additively. It follows palette and mode changes live. It fills its positioned parent, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add forecast-curve ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/forecast-curve.tsx`. 3. Paste the code. ## Usage ```tsx import { ForecastCurve } from "@/components/ui/forecast-curve"; export default function Example() { return (

Content over the background

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :------- | :------------------- | :--------------------------------------------------------------------- | | `speed` | `number` | `1` | Wave speed multiplier. | | `spacing` | `number` | `7` | Grid spacing between dots in pixels. | | `dotSize` | `number` | `5` | Maximum dot size in pixels. | | `archHeight` | `number` | `0.5` | How far the curve rises toward the sides, as a fraction of the height. | | `thickness` | `number` | `1` | Thickness multiplier of the curve band. | | `brightness` | `number` | `1` | Dot opacity multiplier. | | `hue` | `number` | `0` | Hue rotation in degrees. | | `saturation` | `number` | `1` | Saturation multiplier. | | `backgroundColor` | `string` | theme `--background` | Scene background. | ## Source `components/ui/forecast-curve.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Predictive Arc (MIT, © 2026 Meng To). import { useEffect, useRef } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; const readToken = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); export interface ForecastCurveProps { speed?: number; spacing?: number; dotSize?: number; archHeight?: number; thickness?: number; brightness?: number; hue?: number; saturation?: number; backgroundColor?: string; className?: string; } export function ForecastCurve({ speed = 1, spacing = 7, dotSize = 5, archHeight = 0.5, thickness = 1, brightness = 1, hue = 0, saturation = 1, backgroundColor, className, }: ForecastCurveProps) { const prefersReducedMotion = useReducedMotion(); const hostRef = useRef(null); const canvasRef = useRef(null); const options = useRef({ speed, spacing, dotSize, archHeight, thickness, brightness, backgroundColor, }); options.current = { speed, spacing, dotSize, archHeight, thickness, brightness, backgroundColor, }; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; const context = canvas?.getContext("2d", { alpha: false }); if (!host || !canvas || !context) return; let width = 1; let height = 1; let time = 0; let background = ""; let accent = ""; let ink = ""; let isLight = true; const readTheme = () => { background = readToken("--background"); accent = readToken("--primary"); ink = readToken("--muted-foreground"); isLight = Number.parseFloat(background.split(/\s+/)[2] ?? "100") > 50; }; readTheme(); const resize = () => { const bounds = host.getBoundingClientRect(); width = Math.max(1, bounds.width); height = Math.max(1, bounds.height); const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.round(width * pixelRatio); canvas.height = Math.round(height * pixelRatio); context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); }; const render = () => { const o = options.current; context.fillStyle = o.backgroundColor ?? `hsl(${background})`; context.fillRect(0, 0, width, height); time += 0.015 * o.speed; const centerX = width / 2; // A rising curve: low in the middle, lifting toward both edges. const archPeakY = height * 0.72; const archWidth = width * 1.5; const archHeight = height * o.archHeight; context.globalCompositeOperation = isLight ? "source-over" : "lighter"; for (let x = 0; x < width; x += o.spacing) { const normX = (x - centerX) / (archWidth / 2); const curveY = archPeakY - normX * normX * archHeight; for (let y = 0; y < height; y += o.spacing) { const distanceToCurve = Math.abs(y - curveY); const band = (90 + (1 - Math.abs(normX)) * 60) * o.thickness; if (distanceToCurve >= band) continue; let intensity = 1 - distanceToCurve / band; const waveX = Math.sin(x * 0.015 + time); const waveY = Math.cos(y * 0.02 + time); intensity = intensity * 0.7 + waveX * waveY * 0.3 * intensity; intensity *= Math.max(0, 1 - Math.pow(Math.abs(normX), 2.5)); if (intensity <= 0.02) continue; // Cores take the accent; the band's edges stay in muted ink. const alpha = Math.min(1, intensity * o.brightness); context.fillStyle = `hsl(${intensity > 0.6 ? accent : ink} / ${alpha.toFixed(2)})`; context.fillRect(x, y, o.dotSize * intensity, o.dotSize * intensity); } } context.globalCompositeOperation = "source-over"; }; let frame = 0; let visible = true; const tick = () => { render(); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const start = () => { if (prefersReducedMotion || frame || !visible || document.hidden) return; frame = requestAnimationFrame(tick); }; const stop = () => { if (frame) cancelAnimationFrame(frame); frame = 0; }; const resizeObserver = new ResizeObserver(() => { resize(); render(); }); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) start(); else stop(); }); const onVisibility = () => (document.hidden ? stop() : start()); resize(); render(); resizeObserver.observe(host); intersection.observe(host); document.addEventListener("visibilitychange", onVisibility); start(); const themeObserver = new MutationObserver(() => { readTheme(); render(); }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme", "data-palette"], }); return () => { stop(); themeObserver.disconnect(); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); }; }, [prefersReducedMotion]); return (
); } ``` --- # Dew Pane > Fine beads of dew that grow, merge and run down a pane, drawn in theme ink. Install: `npx @modus-ui/cli add dew-pane` A 2D canvas simulates beads that grow in place, then run down, swallowing smaller beads in their path and leaving a primary ripple at the bottom edge. Beads use the theme's muted ink with a paper glint; every colour can be overridden. It fills its positioned parent, follows the active theme and palette in light and dark mode, pauses when off screen and renders a still frame when the user prefers reduced motion. ## Installation ### CLI ```bash npx @modus-ui/cli add dew-pane ``` ### Manual 1. Copy the code from the component tab. 2. Create a file at `components/ui/dew-pane.tsx`. 3. Paste the code. ## Usage ```tsx import { DewPane } from "@/components/ui/dew-pane"; export default function Example() { return (

Content over the background

); } ``` ## Props | Prop | Type | Default | Description | | :---------------- | :------- | :---------- | :---------------------------------------------------------------------- | | `speed` | `number` | `0.8` | Simulation speed multiplier. | | `dropAmount` | `number` | `1.5` | Multiplier on the maximum number of beads. | | `opacity` | `number` | `1` | Overall opacity of the beads. | | `dropColor` | `string` | `undefined` | Hex override for the bead body. Defaults to the theme muted foreground. | | `edgeColor` | `string` | `undefined` | Hex override for the bead rim. Defaults to the theme foreground. | | `highlightColor` | `string` | `undefined` | Hex override for the glint. Defaults to the theme background. | | `splashColor` | `string` | `undefined` | Hex override for the ripple. Defaults to the theme primary. | | `backgroundColor` | `string` | `undefined` | CSS background. Defaults to the theme background. | ## Source `components/ui/dew-pane.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Condensation (MIT, © 2026 Meng To). import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; export interface DewPaneProps { speed?: number; dropAmount?: number; opacity?: number; /** Hex override for the drop body. Defaults to the theme's muted foreground. */ dropColor?: string; /** Hex override for the drop rim. Defaults to the theme foreground. */ edgeColor?: string; /** Hex override for the glint. Defaults to the theme background. */ highlightColor?: string; /** Hex override for the splash ring. Defaults to the theme primary. */ splashColor?: string; /** CSS background. Defaults to the theme background. */ backgroundColor?: string; className?: string; } type Drop = { x: number; y: number; r: number; rMax: number; grow: number; running: boolean; vy: number; }; type Splash = { x: number; y: number; r0: number; born: number }; const TOKENS = [ "primary", "foreground", "background", "muted-foreground", "border", "card", ] as const; type Tokens = Record<(typeof TOKENS)[number], string>; /** Bare HSL channels of the Modus theme tokens, re-read when the theme or palette changes. */ function useThemeTokens(): Tokens | null { const [tokens, setTokens] = useState(null); useEffect(() => { const root = document.documentElement; const read = () => { const style = getComputedStyle(root); const next = Object.fromEntries( TOKENS.map((name) => [name, style.getPropertyValue(`--${name}`).trim()]), ) as Tokens; setTokens((prev) => (prev && TOKENS.every((k) => prev[k] === next[k]) ? prev : next)); }; read(); const observer = new MutationObserver(read); observer.observe(root, { attributes: true, attributeFilter: ["class", "data-theme", "data-palette", "style"], }); return () => observer.disconnect(); }, []); return tokens; } const hexRgb = (hex: string) => { const value = hex.replace("#", ""); const full = value.length === 3 ? [...value].map((c) => c + c).join("") : value; const n = parseInt(full, 16); return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`; }; /** A canvas colour at an alpha: a hex override, or theme channels. */ const tint = (override: string | undefined, channels: string) => override ? (alpha: number) => `rgba(${hexRgb(override)},${alpha})` : (alpha: number) => `hsl(${channels} / ${alpha})`; export function DewPane({ speed = 0.8, dropAmount = 1.5, opacity = 1, dropColor, edgeColor, highlightColor, splashColor, backgroundColor, className, }: DewPaneProps) { const tokens = useThemeTokens(); const prefersReducedMotion = useReducedMotion(); const hostRef = useRef(null); const canvasRef = useRef(null); const options = useRef({ speed, dropAmount }); options.current = { speed, dropAmount }; const colors = tokens ? [ tint(dropColor, tokens["muted-foreground"]), tint(edgeColor, tokens.foreground), tint(highlightColor, tokens.background), tint(splashColor, tokens.primary), ] : null; const colorKey = tokens ? [dropColor, edgeColor, highlightColor, splashColor, ...Object.values(tokens)].join("|") : ""; const colorsRef = useRef(colors); colorsRef.current = colors; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); const palette = colorsRef.current; if (!host || !canvas || !ctx || !palette) return; let width = 1; let height = 1; let dpr = 1; let floorY = 1; let baseMaxDrops = 120; let lastCascade = 0; let now = 0; let last = 0; let drops: Drop[] = []; let splashes: Splash[] = []; const [dropTint, edgeTint, shineTint, splashTint] = palette; const sprites = new Map(); const sprite = (radius: number) => { const key = Math.max(0.5, Math.round(radius * 2) / 2); const cached = sprites.get(key); if (cached) return cached; const padding = Math.ceil(2 * dpr); const size = Math.ceil(key * 2 + padding * 2); const element = document.createElement("canvas"); element.width = size; element.height = size; const c = element.getContext("2d"); if (!c) return element; const center = size / 2; const lit = key / (4.4 * dpr); c.fillStyle = dropTint(0.18 + lit * 0.22); c.beginPath(); c.arc(center, center, key, 0, Math.PI * 2); c.fill(); c.strokeStyle = edgeTint(0.35 + lit * 0.35); c.lineWidth = 0.9 * dpr; c.stroke(); c.fillStyle = shineTint(0.55 + lit * 0.3); c.beginPath(); c.arc( center - key * 0.32, center - key * 0.36, Math.max(0.45 * dpr, key * 0.24), 0, Math.PI * 2, ); c.fill(); sprites.set(key, element); return element; }; const maxDrops = () => Math.round(baseMaxDrops * options.current.dropAmount); const seedDrop = (headStart = 0) => { const margin = 8 * dpr; drops.push({ x: margin + Math.random() * Math.max(1, width - margin * 2), y: margin + Math.random() * Math.max(1, floorY - margin * 2), r: (0.5 + headStart * 0.7) * dpr, rMax: (1.6 + Math.random() * 3.4) * dpr, grow: (0.0022 + Math.random() * 0.0032) * dpr, running: false, vy: 0, }); }; const step = (motion: number) => { if (drops.length < maxDrops() && Math.random() < 0.34 * motion) seedDrop(); if (now - lastCascade > 6400) { lastCascade = now; drops .filter((drop) => !drop.running) .sort((a, b) => b.r - a.r) .slice(0, 3) .forEach((drop) => { drop.running = true; drop.vy = 0.35 * dpr; }); } for (let index = drops.length - 1; index >= 0; index--) { const drop = drops[index]; if (!drop.running) { drop.r += drop.grow * motion; if (drop.r >= drop.rMax) { drop.running = true; drop.vy = 0.3 * dpr; } continue; } drop.vy = Math.min(drop.vy + 0.075 * dpr * motion, 5.4 * dpr); drop.y += drop.vy * motion; // Running drops swallow the still ones in their path. for (let other = drops.length - 1; other >= 0; other--) { const o = drops[other]; if (o === drop || o.running) continue; const overlapsX = Math.abs(o.x - drop.x) < drop.r + o.r + 1.5 * dpr; const overlapsY = o.y > drop.y - drop.r && o.y < drop.y + drop.vy + drop.r; if (overlapsX && overlapsY) { drop.r = Math.sqrt(drop.r * drop.r + o.r * o.r); drops.splice(other, 1); if (other < index) index--; } } if (drop.y >= floorY) { splashes.push({ x: drop.x, y: floorY, r0: drop.r, born: now }); drops.splice(index, 1); } } splashes = splashes.filter((splash) => now - splash.born < 760); }; const render = () => { ctx.clearRect(0, 0, width, height); drops.forEach((drop) => { const image = sprite(drop.r); ctx.drawImage(image, drop.x - image.width / 2, drop.y - image.height / 2); }); splashes.forEach((splash) => { const progress = (now - splash.born) / 760; if (progress < 0 || progress > 1) return; const radius = splash.r0 + progress * 16 * dpr; ctx.lineWidth = 1.1 * dpr; ctx.strokeStyle = splashTint((1 - progress) * 0.7); ctx.beginPath(); ctx.ellipse(splash.x, splash.y, radius, radius * 0.34, 0, 0, Math.PI * 2); ctx.stroke(); }); }; const resize = () => { const rect = host.getBoundingClientRect(); dpr = Math.min(window.devicePixelRatio || 1, 2); width = canvas.width = Math.max(1, Math.floor(rect.width * dpr)); height = canvas.height = Math.max(1, Math.floor(rect.height * dpr)); floorY = Math.max(1, height - 2 * dpr); baseMaxDrops = Math.max(80, Math.min(240, Math.round((width * height) / (dpr * dpr) / 5200))); drops = []; splashes = []; sprites.clear(); const seedCount = Math.round(maxDrops() * 0.62); for (let i = 0; i < seedCount; i++) seedDrop(Math.random() * 3); render(); }; let frame = 0; let visible = true; const tick = (time: number) => { const delta = last ? Math.min(50, time - last) : 16.67; last = time; now += delta * options.current.speed; step(options.current.speed); render(); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const start = () => { if (prefersReducedMotion || frame) return; last = 0; frame = requestAnimationFrame(tick); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) start(); else if (frame) { cancelAnimationFrame(frame); frame = 0; } }); const onVisibility = () => !document.hidden && visible && start(); document.addEventListener("visibilitychange", onVisibility); resizeObserver.observe(host); intersection.observe(host); resize(); start(); return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); drops = []; splashes = []; sprites.clear(); }; }, [prefersReducedMotion, colorKey]); return (
); } ``` --- # Ink Pill > A mono-set pill call-to-action washed in the theme's ink, with a diamond glyph and a paper-card sibling. Install: `npx @modus-ui/cli add ink-pill` A pill call-to-action set in uppercase mono. The primary variant is an ink wash of the theme's primary colour that thins toward the paper, with a soft shadow and a small diamond glyph, and brightens on hover. The ghost variant is a card-coloured pill with a hairline border. Both follow the active theme and palette in light and dark mode. Hue, saturation and brightness grade the button with a CSS filter, and transitions are switched off when the user prefers reduced motion. The component centres the button in its parent and is transparent unless you pass `background`. ## Installation ### CLI ```bash npx @modus-ui/cli add ink-pill ``` ### Manual 1. Install `framer-motion`. 2. Copy the code from the component tab. 3. Create a file at `components/ui/ink-pill.tsx`. 4. Paste the code. ## Usage ```tsx import { InkPill } from "@/components/ui/ink-pill"; export default function Example() { return (
console.log("clicked")} />
); } ``` ## Props | Prop | Type | Default | Description | | :----------- | :------------------------------------- | :---------------- | :----------------------------------------------- | | `variant` | `"primary" \| "ghost"` | `"primary"` | Ink wash pill or card pill. | | `label` | `string` | `"Begin a draft"` | Button text. | | `ring` | `boolean` | `true` | Shows the small diamond glyph after the label. | | `hue` | `number` | `0` | Hue rotation in degrees, clamped to -180 to 180. | | `saturation` | `number` | `1` | Saturation multiplier, clamped to 0 to 2. | | `brightness` | `number` | `1` | Brightness multiplier, clamped to 0.35 to 1.65. | | `disabled` | `boolean` | `false` | Disables the button. | | `type` | `"button" \| "submit" \| "reset"` | `"button"` | Native button type. | | `onClick` | `MouseEventHandler` | `undefined` | Click handler. | | `background` | `string` | `undefined` | Stage background; transparent when omitted. | ## Source `components/ui/ink-pill.tsx` ```tsx "use client"; // Ported from ThreeUI Community's Lumen CTA (MIT, © 2026 Meng To). import type { CSSProperties, MouseEventHandler } from "react"; import { useReducedMotion } from "framer-motion"; import { cn } from "@/lib/utils"; interface InkPillProps { variant?: "primary" | "ghost"; label?: string; ring?: boolean; hue?: number; saturation?: number; brightness?: number; disabled?: boolean; type?: "button" | "submit" | "reset"; onClick?: MouseEventHandler; background?: string; className?: string; } const clamp = (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value)); // An ink wash: full primary on the left, thinning into the paper on the right. const INK_WASH = "linear-gradient(100deg, hsl(var(--primary)) 0%, hsl(var(--primary)) 45%, color-mix(in oklab, hsl(var(--primary)) 62%, hsl(var(--background))) 100%)"; export function InkPill({ variant = "primary", label = "Begin a draft", ring = true, hue = 0, saturation = 1, brightness = 1, disabled = false, type = "button", onClick, background, className, }: InkPillProps) { const prefersReducedMotion = useReducedMotion(); const ghost = variant === "ghost"; const grade = `hue-rotate(${clamp(hue, -180, 180)}deg) saturate(${clamp(saturation, 0, 2)})`; const level = clamp(brightness, 0.35, 1.65); const transition = prefersReducedMotion ? "none" : "filter 0.25s ease, background 0.25s ease, transform 0.25s ease"; const buttonStyle: CSSProperties = ghost ? { filter: `${grade} brightness(${level})`, transition } : { background: INK_WASH, boxShadow: "0 10px 24px -12px hsl(var(--primary) / 0.55), inset 0 1px 0 hsl(var(--primary-foreground) / 0.18)", filter: `${grade} brightness(calc(${level} * var(--ink-pill-lift, 1)))`, transition, }; return (