# Satisium UI - Complete AI Knowledge Base > Free, open-source animated React component library for Design Engineers. > Built with Next.js, Tailwind CSS v4, Framer Motion, and GSAP. ## How to install components Add any component to a Shadcn-compatible project: ```bash npx shadcn@latest add https://ui.satisium.com/r/.json ``` ## System instructions (CRITICAL) - Always use `motion/react` (Framer Motion v12+) for animations. - Use Tailwind CSS v4 utility classes. - Use `@hugeicons/react` for iconography. - Components must use the `"use client"` directive where client-side hooks are required. - All components are SSR-safe and include `prefers-reduced-motion` fallbacks. --- # Ascent Carousel Component Context **Description:** A high-performance, exponential 3D-perspective carousel for Satis UI. Items mathematically scale down as they retreat to the right, creating a profound sense of spatial depth using strictly 2D DOM manipulation for maximum FPS. Integrates a GSAP ticker loop and GSAP Observer for flawless touch, drag, and wheel physics. Includes robust GC cleanup and reduced-motion vestibular failsafes that automatically disable infinite scrolling loops. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/ascent-carousel.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------- | :--------------------------- | :------------- | :------------------------------------------------------------ | | `items` | `AscentCarouselItem[]` | _Required_ | Image array (`{ id, url, alt }`). | | `visibleItems` | `number` | `6` | Base visible items. | | `maxHeight` | `number` | `600` | Foreground height (px). | | `minHeight` | `number` | `150` | Background height (px). | | `breakpoints` | `Record` | `undefined` | Mobile-first overrides (e.g. `{ 640: { visibleItems: 8 } }`). | | `autoMove` | `boolean` | `false` | Enable autoplay. | | `autoMoveType` | `"continuous" \| "step"` | `"continuous"` | Autoplay behavior mode. | | `autoMoveSpeed` | `number` | `0.01` | Continuous drift velocity. | | `stepInterval` | `number` | `3000` | Delay between step snaps (ms). | | `stepDuration` | `number` | `1` | Step snap duration (s). | | `scrollMultiplier` | `number` | `0.005` | Drag sensitivity multiplier. | | `friction` | `number` | `0.95` | Momentum friction decay. | ## 3. Core Component Source **File Path:** `registry/ui/ascent-carousel.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface AscentCarouselItem { id: string url: string alt?: string } export interface CarouselBreakpoint { visibleItems?: number maxHeight?: number minHeight?: number } export interface AscentCarouselProps { items: AscentCarouselItem[] visibleItems?: number maxHeight?: number minHeight?: number breakpoints?: Record autoMove?: boolean autoMoveType?: "continuous" | "step" autoMoveSpeed?: number stepInterval?: number stepDuration?: number scrollMultiplier?: number friction?: number className?: string } export function AscentCarousel({ items, visibleItems = 6, maxHeight = 600, minHeight = 150, breakpoints, autoMove = false, autoMoveType = "continuous", autoMoveSpeed = 0.01, stepInterval = 3000, stepDuration = 1, scrollMultiplier = 0.005, friction = 0.95, className, }: AscentCarouselProps) { const containerRef = React.useRef(null) const wrapperRef = React.useRef(null) const itemsRef = React.useRef<(HTMLDivElement | null)[]>([]) const progressRef = React.useRef(0) const velocityRef = React.useRef(0) const maxRequiredVisible = React.useMemo(() => { let max = visibleItems if (breakpoints) { Object.values(breakpoints).forEach((bp) => { if (bp.visibleItems && bp.visibleItems > max) max = bp.visibleItems }) } return max }, [visibleItems, breakpoints]) const extendedItems = React.useMemo(() => { const minRequired = maxRequiredVisible * 4 let duplicated: AscentCarouselItem[] = [...items] while (duplicated.length < minRequired) { duplicated = [...duplicated, ...items] } return duplicated.map((item, i) => ({ ...item, _uniqueId: `${item.id}-${i}`, })) }, [items, maxRequiredVisible]) useGSAP( () => { if (!containerRef.current || itemsRef.current.length === 0) return const mm = gsap.matchMedia() let isReducedMotion = false mm.add("(prefers-reduced-motion: reduce)", () => { isReducedMotion = true }) let activeVis = visibleItems let activeMaxH = maxHeight let activeMinH = minHeight let windowWidth = window.innerWidth const updateConfig = () => { windowWidth = window.innerWidth const containerHeight = containerRef.current?.clientHeight || window.innerHeight activeVis = visibleItems let configuredMaxH = maxHeight let configuredMinH = minHeight if (breakpoints) { const bps = Object.keys(breakpoints).map(Number).sort((a, b) => a - b) for (const bp of bps) { if (windowWidth >= bp) { if (breakpoints[bp].visibleItems) activeVis = breakpoints[bp].visibleItems if (breakpoints[bp].maxHeight) configuredMaxH = breakpoints[bp].maxHeight if (breakpoints[bp].minHeight) configuredMinH = breakpoints[bp].minHeight } } } activeMaxH = Math.min(configuredMaxH, containerHeight) activeMinH = Math.min(configuredMinH, activeMaxH * 0.8) } const onResize = () => updateConfig() window.addEventListener("resize", onResize) onResize() let stepTween: gsap.core.Tween | null = null let stepTimer: gsap.core.Tween | null = null const scheduleNextStep = () => { if (stepTimer) stepTimer.kill() if (stepTween) stepTween.kill() if (isReducedMotion) return if (autoMove && autoMoveType === "step") { stepTimer = gsap.delayedCall(stepInterval / 1000, () => { const currentP = progressRef.current const stepSign = autoMoveSpeed >= 0 ? -1 : 1 const targetP = Math.round(currentP) + stepSign const dist = targetP - currentP let lastProxy = 0 const proxy = { x: 0 } stepTween = gsap.to(proxy, { x: dist, duration: stepDuration, ease: "power2.inOut", onUpdate: () => { const delta = proxy.x - lastProxy progressRef.current += delta lastProxy = proxy.x velocityRef.current = 0 }, onComplete: scheduleNextStep, }) }) } } scheduleNextStep() const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onPress: () => { if (stepTween) stepTween.kill() if (stepTimer) stepTimer.kill() }, onWheel: (e) => { if (stepTween) stepTween.kill() velocityRef.current -= e.deltaY * scrollMultiplier scheduleNextStep() }, onDrag: (e) => { velocityRef.current -= e.deltaX * scrollMultiplier }, onRelease: () => scheduleNextStep(), }) const totalItems = extendedItems.length const update = () => { velocityRef.current *= friction if (Math.abs(velocityRef.current) < 0.0001) velocityRef.current = 0 let velocity = velocityRef.current if (!isReducedMotion && autoMove && autoMoveType === "continuous") { velocity -= autoMoveSpeed } progressRef.current += velocity const progress = ((progressRef.current % totalItems) + totalItems) % totalItems const W_screen = windowWidth const shiftOffset = Math.floor((totalItems - activeVis) / 2) const A = Math.min(activeMinH / activeMaxH, 0.999) const B = (1 - A) / W_screen const R = Math.pow(1 / A, 1 / activeVis) const W_max = ((2 * W_screen) / (1 - A)) * ((R - 1) / (R + 1)) itemsRef.current.forEach((el, i) => { if (!el) return let u = (((i - progress) % totalItems) + totalItems) % totalItems if (u > activeVis + shiftOffset) u -= totalItems const scale = A * Math.pow(R, u) const x = (A / B) * (Math.pow(R, u) - 1) const isOffScreen = x < -W_max * 2 || x > W_screen + W_max * 2 gsap.set(el, { x: x - W_max / 2, scale: scale, width: W_max, height: activeMaxH, transformOrigin: "bottom center", autoAlpha: isOffScreen ? 0 : 1, force3D: true, }) }) } update() gsap.to(wrapperRef.current, { opacity: 1, duration: 0.5, ease: "power2.out" }) gsap.ticker.add(update) return () => { window.removeEventListener("resize", onResize) gsap.ticker.remove(update) observer.kill() if (stepTween) stepTween.kill() if (stepTimer) stepTimer.kill() } }, { scope: containerRef, dependencies: [ extendedItems, visibleItems, maxHeight, minHeight, breakpoints, autoMove, autoMoveType, autoMoveSpeed, stepInterval, stepDuration, scrollMultiplier, friction, ], } ) return (
{extendedItems.map((item, index) => (
{ itemsRef.current[index] = el }} role="group" aria-roledescription="slide" aria-label={`Slide ${index + 1} of ${extendedItems.length}`} className="absolute bottom-0 flex items-end justify-center will-change-transform overflow-hidden" style={{ transformOrigin: "bottom center" }} > {/* eslint-disable-next-line @next/next/no-img-element */} {item.alt
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { AscentCarousel } from "@/registry/ui/ascent-carousel" export default function ExamplePage() { return (
) } ``` --- # Blur Reveal Component Context **Description:** A cinematic, 3D text reveal component for Satis UI. Characters or words sweep in from an angled, blurry 3D perspective, utilizing deep easing to create a heavy, dramatic reveal. Features a built-in GSAP `clearProps` cleanup mechanism to guarantee flawless native anti-aliasing once the animation finishes. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/blur-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :------------- | :---------------------------- | | `text` | `string` | _Required_ | The string of text to reveal. | | `as` | `React.ElementType` | `"p"` | HTML tag to render as. | | `splitBy` | `"word" \| "char"` | `"word"` | Split mode. | | `blur` | `boolean` | `true` | Apply cinematic blur filter. | | `duration` | `number` | `1` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.03` | Stagger timing between items. | | `ease` | `string` | `"power3.out"` | Easing curve. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 90%"` | ScrollTrigger start position. | ## 3. Core Component Source **File Path:** `registry/ui/blur-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface BlurRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" blur?: boolean duration?: number delay?: number stagger?: number ease?: string viewportOnce?: boolean triggerStart?: string } export const BlurReveal = React.forwardRef< HTMLElement, BlurRevealProps >( ( { text, as = "p", className, splitBy = "word", blur = true, duration = 1, delay = 0, stagger = 0.03, ease = "power3.out", viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".blur-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { opacity: 0, y: 40, rotateX: -50, filter: blur ? "blur(12px)" : "none", }, { opacity: 1, y: 0, rotateX: 0, filter: blur ? "blur(0px)" : "none", duration, delay, stagger, ease, force3D: true, clearProps: blur ? "filter" : "", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ blur, duration, delay, stagger, ease, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: "translateY(40px) rotateX(-50deg)", filter: blur ? "blur(12px)" : "none", transformOrigin: "bottom center", willChange: "transform, opacity, filter", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) BlurReveal.displayName = "BlurReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { BlurReveal } from "@/registry/ui/blur-reveal" export default function ExamplePage() { return (
) } ``` --- # Bottom Hinge Text Reveal Component Context **Description:** A high-impact 3D text reveal component for Satis UI. Elements start deep in the Z-axis, leaning backward, and aggressively swing up and slam into place using a tight perspective and heavy GSAP overshoot. Resolves correctly with strict `clearProps` anti-aliasing logic. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/bottom-hinge-text-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :---------- | :---------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `splitBy` | `"word" \| "char"` | `"char"` | Split mode. | | `startZ` | `string \| number` | `"-400px"` | Starting Z-depth. | | `startAngleX` | `number` | `-70` | Starting X-axis rotation. | | `duration` | `number` | `0.7` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.04` | Stagger timing between items. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 90%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/bottom-hinge-text-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface BottomHingeTextRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" startZ?: string | number startAngleX?: number duration?: number delay?: number stagger?: number viewportOnce?: boolean triggerStart?: string } export const BottomHingeTextReveal = React.forwardRef< HTMLElement, BottomHingeTextRevealProps >( ( { text, as = "h1", className, splitBy = "char", startZ = "-400px", startAngleX = -70, duration = 0.7, delay = 0, stagger = 0.04, viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) const resolveZ = typeof startZ === "number" ? `${startZ}px` : startZ useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".hinge-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { z: resolveZ, rotateX: startAngleX, opacity: 0, }, { z: 0, rotateX: 0, opacity: 1, duration, delay, stagger, ease: "back.out(2.5)", force3D: true, clearProps: "transform,opacity", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", clearProps: "transform", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ resolveZ, startAngleX, duration, delay, stagger, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: `translateZ(${resolveZ}) rotateX(${startAngleX}deg)`, transformOrigin: "bottom center", transformStyle: "preserve-3d", willChange: "transform, opacity", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) BottomHingeTextReveal.displayName = "BottomHingeTextReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { BottomHingeTextReveal } from "@/registry/ui/bottom-hinge-text-reveal" export default function ExamplePage() { return (
) } ``` --- # Concave Carousel Component Context **Description:** A high-performance, physics-based infinite carousel for Satis UI. Items mathematically scale down as they reach the edges, creating a 3D "concave" curved visual effect using strictly 2D DOM manipulation for maximum FPS. Integrates a GSAP ticker loop and GSAP Observer for flawless touch, drag, and wheel physics. Includes robust GC cleanup and reduced-motion vestibular failsafes that automatically disable infinite scrolling loops. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/concave-carousel.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------- | :--------------------------- | :------------- | :------------------------------------------------------------ | | `items` | `ConcaveCarouselItem[]` | _Required_ | Image array (`{ id, url, alt }`). | | `visibleItems` | `number` | `3` | Base visible items. | | `maxHeight` | `number` | `250` | Center height (px). | | `minHeight` | `number` | `100` | Edge height (px). | | `breakpoints` | `Record` | `undefined` | Mobile-first overrides (e.g. `{ 640: { visibleItems: 5 } }`). | | `autoMove` | `boolean` | `false` | Enable autoplay. | | `autoMoveType` | `"continuous" \| "step"` | `"continuous"` | Autoplay behavior mode. | | `autoMoveSpeed` | `number` | `0.01` | Continuous drift velocity. | | `stepInterval` | `number` | `3000` | Delay between step snaps (ms). | | `stepDuration` | `number` | `1` | Step snap duration (s). | | `scrollMultiplier` | `number` | `0.003` | Drag sensitivity multiplier. | | `friction` | `number` | `0.95` | Momentum friction decay. | ## 3. Core Component Source **File Path:** `registry/ui/concave-carousel.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface ConcaveCarouselItem { id: string url: string alt?: string } export interface CarouselBreakpoint { visibleItems?: number maxHeight?: number minHeight?: number } export interface ConcaveCarouselProps { items: ConcaveCarouselItem[] visibleItems?: number maxHeight?: number minHeight?: number breakpoints?: Record autoMove?: boolean autoMoveType?: "continuous" | "step" autoMoveSpeed?: number stepInterval?: number stepDuration?: number scrollMultiplier?: number friction?: number className?: string } export function ConcaveCarousel({ items, visibleItems = 3, maxHeight = 250, minHeight = 100, breakpoints, autoMove = false, autoMoveType = "continuous", autoMoveSpeed = 0.01, stepInterval = 3000, stepDuration = 1, scrollMultiplier = 0.003, friction = 0.95, className, }: ConcaveCarouselProps) { const containerRef = React.useRef(null) const wrapperRef = React.useRef(null) const itemsRef = React.useRef<(HTMLDivElement | null)[]>([]) const scrollXRef = React.useRef(0) const velocityRef = React.useRef(0) const maxRequiredVisible = React.useMemo(() => { let max = visibleItems if (breakpoints) { Object.values(breakpoints).forEach((bp) => { if (bp.visibleItems && bp.visibleItems > max) max = bp.visibleItems }) } return max }, [visibleItems, breakpoints]) const extendedItems = React.useMemo(() => { const minRequired = maxRequiredVisible * 3 let duplicated: ConcaveCarouselItem[] = [...items] while (duplicated.length < minRequired) { duplicated = [...duplicated, ...items] } return duplicated.map((item, i) => ({ ...item, _uniqueId: `${item.id}-${i}`, })) }, [items, maxRequiredVisible]) useGSAP( () => { if (!containerRef.current || itemsRef.current.length === 0) return const mm = gsap.matchMedia() let isReducedMotion = false mm.add("(prefers-reduced-motion: reduce)", () => { isReducedMotion = true }) let activeVis = visibleItems let activeMaxH = maxHeight let activeMinH = minHeight let activeMinScale = activeMinH / activeMaxH let windowWidth = window.innerWidth let maxWidth = 0 const updateConfig = () => { windowWidth = window.innerWidth activeVis = visibleItems activeMaxH = maxHeight activeMinH = minHeight if (breakpoints) { const bps = Object.keys(breakpoints) .map(Number) .sort((a, b) => a - b) for (const bp of bps) { if (windowWidth >= bp) { if (breakpoints[bp].visibleItems) activeVis = breakpoints[bp].visibleItems if (breakpoints[bp].maxHeight) activeMaxH = breakpoints[bp].maxHeight if (breakpoints[bp].minHeight) activeMinH = breakpoints[bp].minHeight } } } activeMinScale = activeMinH / activeMaxH const integralAvgScale = activeMinScale + (1 - activeMinScale) / 3 maxWidth = windowWidth / (activeVis * integralAvgScale) } const onResize = () => updateConfig() window.addEventListener("resize", onResize) onResize() let stepTween: gsap.core.Tween | null = null let stepTimer: gsap.core.Tween | null = null const scheduleNextStep = () => { if (stepTimer) stepTimer.kill() if (stepTween) stepTween.kill() if (isReducedMotion) return if (autoMove && autoMoveType === "step") { stepTimer = gsap.delayedCall(stepInterval / 1000, () => { const currentX = scrollXRef.current const stepSign = autoMoveSpeed >= 0 ? -1 : 1 const targetX = Math.round(currentX) + stepSign const dist = targetX - currentX let lastProxy = 0 const proxy = { x: 0 } stepTween = gsap.to(proxy, { x: dist, duration: stepDuration, ease: "power2.inOut", onUpdate: () => { const delta = proxy.x - lastProxy scrollXRef.current += delta lastProxy = proxy.x velocityRef.current = 0 }, onComplete: scheduleNextStep, }) }) } } scheduleNextStep() const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onPress: () => { if (stepTween) stepTween.kill() if (stepTimer) stepTimer.kill() }, onWheel: (e) => { if (stepTween) stepTween.kill() velocityRef.current -= e.deltaY * scrollMultiplier scheduleNextStep() }, onDrag: (e) => { velocityRef.current -= e.deltaX * scrollMultiplier }, onRelease: () => scheduleNextStep(), }) const totalItems = extendedItems.length const update = () => { velocityRef.current *= friction if (Math.abs(velocityRef.current) < 0.0001) velocityRef.current = 0 let velocity = velocityRef.current if (!isReducedMotion && autoMove && autoMoveType === "continuous") { velocity -= autoMoveSpeed } scrollXRef.current += velocity scrollXRef.current = ((scrollXRef.current % totalItems) + totalItems) % totalItems const scrollX = scrollXRef.current const leftIdx = Math.floor(scrollX) const rightIdx = (leftIdx + 1) % totalItems const frac = scrollX - leftIdx const layoutData = new Array(totalItems).fill({ scale: 1, width: maxWidth, x: 0, }) for (let i = 0; i < totalItems; i++) { const d1 = Math.abs(i - scrollX) const d2 = Math.abs(i - (scrollX - totalItems)) const d3 = Math.abs(i - (scrollX + totalItems)) const dist = Math.min(d1, d2, d3) const normalizedD = Math.min(dist / (activeVis / 2), 1) const scale = activeMinScale + (1 - activeMinScale) * (normalizedD * normalizedD) layoutData[i] = { scale, width: maxWidth * scale, x: 0 } } const centerScreenX = windowWidth / 2 layoutData[leftIdx].x = centerScreenX - frac * (layoutData[leftIdx].width / 2 + layoutData[rightIdx].width / 2) layoutData[rightIdx].x = layoutData[leftIdx].x + layoutData[leftIdx].width / 2 + layoutData[rightIdx].width / 2 let currR = rightIdx for (let step = 1; step <= Math.floor(totalItems / 2); step++) { const next = (currR + 1) % totalItems layoutData[next].x = layoutData[currR].x + layoutData[currR].width / 2 + layoutData[next].width / 2 currR = next } let currL = leftIdx for (let step = 1; step <= Math.floor(totalItems / 2); step++) { const prev = (currL - 1 + totalItems) % totalItems layoutData[prev].x = layoutData[currL].x - layoutData[currL].width / 2 - layoutData[prev].width / 2 currL = prev } itemsRef.current.forEach((el, i) => { if (!el) return const data = layoutData[i] const isOffScreen = data.x < -maxWidth * 2 || data.x > windowWidth + maxWidth * 2 gsap.set(el, { x: data.x - maxWidth / 2, scale: data.scale, width: maxWidth, height: activeMaxH, transformOrigin: "bottom center", autoAlpha: isOffScreen ? 0 : 1, force3D: true, }) }) } update() gsap.to(wrapperRef.current, { opacity: 1, duration: 0.5, ease: "power2.out" }) gsap.ticker.add(update) return () => { window.removeEventListener("resize", onResize) gsap.ticker.remove(update) observer.kill() if (stepTween) stepTween.kill() if (stepTimer) stepTimer.kill() } }, { scope: containerRef, dependencies: [ extendedItems, visibleItems, maxHeight, minHeight, breakpoints, autoMove, autoMoveType, autoMoveSpeed, stepInterval, stepDuration, scrollMultiplier, friction, ], } ) return (
{extendedItems.map((item, index) => (
{ itemsRef.current[index] = el }} role="group" aria-roledescription="slide" aria-label={`Slide ${index + 1} of ${extendedItems.length}`} className="absolute bottom-0 flex items-end justify-center overflow-hidden will-change-transform" style={{ transformOrigin: "bottom center" }} > {/* eslint-disable-next-line @next/next/no-img-element */} {item.alt
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { ConcaveCarousel } from "@/registry/ui/concave-carousel" export default function ExamplePage() { return (
) } ``` --- # Convex Carousel Component Context **Description:** A high-performance, physics-based infinite carousel for Satis UI. Items mathematically scale based on an inverted parabola, creating a 3D "convex" lens bulging visual effect using strictly 2D DOM manipulation for maximum FPS. Integrates a GSAP ticker loop and GSAP Observer for flawless touch, drag, and wheel physics. Includes robust GC cleanup and reduced-motion vestibular failsafes that automatically disable infinite scrolling loops. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/convex-carousel.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------- | :--------------------------- | :------------- | :------------------------------------------------------------ | | `items` | `ConvexCarouselItem[]` | _Required_ | Image array (`{ id, url, alt }`). | | `visibleItems` | `number` | `7` | Base visible items. | | `maxHeight` | `number` | `450` | Center height (px). | | `minHeight` | `number` | `120` | Edge height (px). | | `breakpoints` | `Record` | `undefined` | Mobile-first overrides (e.g. `{ 640: { visibleItems: 5 } }`). | | `autoMove` | `boolean` | `false` | Enable autoplay. | | `autoMoveType` | `"continuous" \| "step"` | `"continuous"` | Autoplay behavior mode. | | `autoMoveSpeed` | `number` | `0.01` | Continuous drift velocity. | | `stepInterval` | `number` | `3000` | Delay between step snaps (ms). | | `stepDuration` | `number` | `1` | Step snap duration (s). | | `scrollMultiplier` | `number` | `0.005` | Drag sensitivity multiplier. | | `friction` | `number` | `0.95` | Momentum friction decay. | ## 3. Core Component Source **File Path:** `registry/ui/convex-carousel.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface ConvexCarouselItem { id: string url: string alt?: string } export interface CarouselBreakpoint { visibleItems?: number maxHeight?: number minHeight?: number } export interface ConvexCarouselProps { items: ConvexCarouselItem[] visibleItems?: number maxHeight?: number minHeight?: number breakpoints?: Record autoMove?: boolean autoMoveType?: "continuous" | "step" autoMoveSpeed?: number stepInterval?: number stepDuration?: number scrollMultiplier?: number friction?: number className?: string } export function ConvexCarousel({ items, visibleItems = 7, maxHeight = 450, minHeight = 120, breakpoints, autoMove = false, autoMoveType = "continuous", autoMoveSpeed = 0.01, stepInterval = 3000, stepDuration = 1, scrollMultiplier = 0.005, friction = 0.95, className, }: ConvexCarouselProps) { const containerRef = React.useRef(null) const wrapperRef = React.useRef(null) const itemsRef = React.useRef<(HTMLDivElement | null)[]>([]) const scrollXRef = React.useRef(0) const velocityRef = React.useRef(0) const maxRequiredVisible = React.useMemo(() => { let max = visibleItems if (breakpoints) { Object.values(breakpoints).forEach((bp) => { if (bp.visibleItems && bp.visibleItems > max) max = bp.visibleItems }) } return max }, [visibleItems, breakpoints]) const extendedItems = React.useMemo(() => { const minRequired = maxRequiredVisible * 3 let duplicated: ConvexCarouselItem[] = [...items] while (duplicated.length < minRequired) { duplicated = [...duplicated, ...items] } return duplicated.map((item, i) => ({ ...item, _uniqueId: `${item.id}-${i}`, })) }, [items, maxRequiredVisible]) useGSAP( () => { if (!containerRef.current || itemsRef.current.length === 0) return const mm = gsap.matchMedia() let isReducedMotion = false mm.add("(prefers-reduced-motion: reduce)", () => { isReducedMotion = true }) let activeVis = visibleItems let activeMaxH = maxHeight let activeMinH = minHeight let activeMinScale = activeMinH / activeMaxH let windowWidth = window.innerWidth let maxWidth = 0 const updateConfig = () => { windowWidth = window.innerWidth const containerHeight = containerRef.current?.clientHeight || window.innerHeight activeVis = visibleItems let configuredMaxH = maxHeight let configuredMinH = minHeight if (breakpoints) { const bps = Object.keys(breakpoints).map(Number).sort((a, b) => a - b) for (const bp of bps) { if (windowWidth >= bp) { if (breakpoints[bp].visibleItems) activeVis = breakpoints[bp].visibleItems if (breakpoints[bp].maxHeight) configuredMaxH = breakpoints[bp].maxHeight if (breakpoints[bp].minHeight) configuredMinH = breakpoints[bp].minHeight } } } activeMaxH = Math.min(configuredMaxH, containerHeight) activeMinH = Math.min(configuredMinH, activeMaxH * 0.8) activeMinScale = activeMinH / activeMaxH const integralAvgScale = activeMinScale + (1 - activeMinScale) * (2 / 3) maxWidth = windowWidth / (activeVis * integralAvgScale) } const onResize = () => updateConfig() window.addEventListener("resize", onResize) onResize() let stepTween: gsap.core.Tween | null = null let stepTimer: gsap.core.Tween | null = null const scheduleNextStep = () => { if (stepTimer) stepTimer.kill() if (stepTween) stepTween.kill() if (isReducedMotion) return if (autoMove && autoMoveType === "step") { stepTimer = gsap.delayedCall(stepInterval / 1000, () => { const currentX = scrollXRef.current const stepSign = autoMoveSpeed >= 0 ? 1 : -1 const targetX = Math.round(currentX) + stepSign const dist = targetX - currentX let lastProxy = 0 const proxy = { x: 0 } stepTween = gsap.to(proxy, { x: dist, duration: stepDuration, ease: "power2.inOut", onUpdate: () => { const delta = proxy.x - lastProxy scrollXRef.current += delta lastProxy = proxy.x velocityRef.current = 0 }, onComplete: scheduleNextStep, }) }) } } scheduleNextStep() const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onPress: () => { if (stepTween) stepTween.kill() if (stepTimer) stepTimer.kill() }, onWheel: (e) => { if (stepTween) stepTween.kill() velocityRef.current += e.deltaY * scrollMultiplier scheduleNextStep() }, onDrag: (e) => { velocityRef.current -= e.deltaX * scrollMultiplier }, onRelease: () => scheduleNextStep(), }) const totalItems = extendedItems.length const update = () => { velocityRef.current *= friction if (Math.abs(velocityRef.current) < 0.0001) velocityRef.current = 0 let velocity = velocityRef.current if (!isReducedMotion && autoMove && autoMoveType === "continuous") { velocity += autoMoveSpeed } scrollXRef.current += velocity scrollXRef.current = ((scrollXRef.current % totalItems) + totalItems) % totalItems const scrollX = scrollXRef.current const leftIdx = Math.floor(scrollX) const rightIdx = (leftIdx + 1) % totalItems const frac = scrollX - leftIdx const layoutData = new Array(totalItems).fill({ scale: 1, width: maxWidth, x: 0, }) for (let i = 0; i < totalItems; i++) { const d1 = Math.abs(i - scrollX) const d2 = Math.abs(i - (scrollX - totalItems)) const d3 = Math.abs(i - (scrollX + totalItems)) const dist = Math.min(d1, d2, d3) const normalizedD = Math.min(dist / (activeVis / 2), 1) const scale = activeMinScale + (1 - activeMinScale) * (1 - normalizedD * normalizedD) layoutData[i] = { scale, width: maxWidth * scale, x: 0 } } const centerScreenX = windowWidth / 2 layoutData[leftIdx].x = centerScreenX - frac * (layoutData[leftIdx].width / 2 + layoutData[rightIdx].width / 2) layoutData[rightIdx].x = layoutData[leftIdx].x + layoutData[leftIdx].width / 2 + layoutData[rightIdx].width / 2 let currR = rightIdx for (let step = 1; step <= Math.floor(totalItems / 2); step++) { const next = (currR + 1) % totalItems layoutData[next].x = layoutData[currR].x + layoutData[currR].width / 2 + layoutData[next].width / 2 currR = next } let currL = leftIdx for (let step = 1; step <= Math.floor(totalItems / 2); step++) { const prev = (currL - 1 + totalItems) % totalItems layoutData[prev].x = layoutData[currL].x - layoutData[currL].width / 2 - layoutData[prev].width / 2 currL = prev } itemsRef.current.forEach((el, i) => { if (!el) return const data = layoutData[i] const isOffScreen = data.x < -maxWidth * 2 || data.x > windowWidth + maxWidth * 2 gsap.set(el, { x: data.x - maxWidth / 2, scale: data.scale, width: maxWidth, height: activeMaxH, transformOrigin: "bottom center", autoAlpha: isOffScreen ? 0 : 1, force3D: true, }) }) } update() gsap.to(wrapperRef.current, { opacity: 1, duration: 0.5, ease: "power2.out" }) gsap.ticker.add(update) return () => { window.removeEventListener("resize", onResize) gsap.ticker.remove(update) observer.kill() if (stepTween) stepTween.kill() if (stepTimer) stepTimer.kill() } }, { scope: containerRef, dependencies: [ extendedItems, visibleItems, breakpoints, autoMove, autoMoveType, autoMoveSpeed, stepInterval, stepDuration, scrollMultiplier, friction, maxHeight, minHeight, ], } ) return (
{extendedItems.map((item, index) => (
{ itemsRef.current[index] = el }} role="group" aria-roledescription="slide" aria-label={`Slide ${index + 1} of ${extendedItems.length}`} className="absolute bottom-0 flex items-end justify-center overflow-hidden will-change-transform" style={{ transformOrigin: "bottom center" }} > {/* eslint-disable-next-line @next/next/no-img-element */} {item.alt
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { ConvexCarousel } from "@/registry/ui/convex-carousel" export default function ExamplePage() { return (
) } ``` --- # Cover Carousel Component Context **Description:** A highly sophisticated Cover Flow 3D carousel for Satis UI. Renders an immersive, hardware-accelerated stack of images using custom GLSL shaders for dynamic ambient occlusion, internal parallax, and SDF rounded corners. Built with React Three Fiber and GSAP's Observer plugin for seamless, multi-directional scroll and swipe physics. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/cover-carousel.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :--------------------- | :--------- | :--------- | :------------------------------ | | `images` | `string[]` | _Required_ | Array of image URLs. | | `cardWidthRatio` | `number` | `0.35` | Screen width ratio. | | `cardAspectRatio` | `number` | `1.4` | Width/Height aspect ratio. | | `scrollSensitivity` | `number` | `0.003` | Scroll input multiplier. | | `lerpFactor` | `number` | `0.06` | Smooth momentum decay. | | `parallaxIntensity` | `number` | `0.08` | Internal texture slide. | | `activeGapMultiplier` | `number` | `0.55` | Spacing around active card. | | `stackGapMultiplier` | `number` | `0.15` | Spacing between inactive cards. | | `maxZOffsetMultiplier` | `number` | `0.4` | Depth push. | | `rotationMultiplier` | `number` | `0.8` | Max Y-rotation angle. | | `scaleMultiplier` | `number` | `0.15` | Max background scaling. | | `dimmingFactor` | `number` | `0.85` | Ambient occlusion darkness. | | `cornerRadius` | `number` | `0.04` | SDF corner radius limit. | | `shadowOpacity` | `number` | `0.6` | Grounded shadow strength. | ## 3. Core Component Source **File Path:** `registry/ui/cover-carousel.tsx` ```tsx "use client" import React, { useRef, useState, useMemo, useEffect } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { useTexture, ContactShadows } from "@react-three/drei" import * as THREE from "three" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface CoverCarouselProps extends Omit< React.HTMLAttributes, "children" > { images: string[] className?: string cardWidthRatio?: number cardAspectRatio?: number scrollSensitivity?: number lerpFactor?: number parallaxIntensity?: number activeGapMultiplier?: number stackGapMultiplier?: number maxZOffsetMultiplier?: number stackZOffsetMultiplier?: number rotationMultiplier?: number scaleMultiplier?: number dimmingFactor?: number cornerRadius?: number shadowOpacity?: number } interface ScrollState { target: number current: number isDragging: boolean min: number max: number } const CoverVertexShader = ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` const CoverFragmentShader = ` precision mediump float; uniform sampler2D uTexture; uniform vec2 uResolution; uniform float uImageAspect; uniform float uActive; uniform float uParallax; uniform float uParallaxIntensity; uniform float uCornerRadius; uniform float uDimmingFactor; varying vec2 vUv; void main() { float screenAspect = uResolution.x / uResolution.y; vec2 scale = vec2(1.0); if (screenAspect > uImageAspect) { scale.y = uImageAspect / screenAspect; } else { scale.x = screenAspect / uImageAspect; } vec2 parallaxUv = (vUv - 0.5) * (scale * 0.85) + 0.5; parallaxUv.x += clamp(uParallax, -1.0, 1.0) * uParallaxIntensity; vec4 texColor = texture2D(uTexture, parallaxUv); vec2 pos = vUv - 0.5; vec2 pixelPos = pos * uResolution; vec2 pixelSize = vec2(0.5) * uResolution; float pixelRadius = uCornerRadius * min(uResolution.x, uResolution.y); float dist = length(max(abs(pixelPos) - pixelSize + pixelRadius, 0.0)) - pixelRadius; float alpha = 1.0 - smoothstep(0.0, 1.5, dist); float dimBaseline = 1.0 - uDimmingFactor; vec3 color = mix(texColor.rgb * dimBaseline, texColor.rgb, uActive); gl_FragColor = vec4(color, alpha); } ` function CoverScene({ images, scrollState, onReady, cardWidthRatio, cardAspectRatio, lerpFactor, parallaxIntensity, activeGapMultiplier, stackGapMultiplier, maxZOffsetMultiplier, stackZOffsetMultiplier, rotationMultiplier, scaleMultiplier, dimmingFactor, cornerRadius, shadowOpacity, }: CoverCarouselProps & { scrollState: React.MutableRefObject onReady: () => void }) { const textures = useTexture(images) const { viewport } = useThree() const groupRef = useRef(null) const isMobile = viewport.width < 5 let itemWidth = isMobile ? viewport.width * 0.65 : viewport.width * cardWidthRatio! let itemHeight = itemWidth * cardAspectRatio! const maxHeight = viewport.height * (isMobile ? 0.6 : 0.65) if (itemHeight > maxHeight) { itemHeight = maxHeight itemWidth = itemHeight / cardAspectRatio! } const geometry = useMemo( () => new THREE.PlaneGeometry(itemWidth, itemHeight, 1, 1), [itemWidth, itemHeight] ) const materials = useMemo(() => { return textures.map((texture) => { const img = texture.image as | { width?: number; height?: number } | null | undefined const imageAspect = img?.width && img?.height ? img.width / img.height : 1 return new THREE.ShaderMaterial({ vertexShader: CoverVertexShader, fragmentShader: CoverFragmentShader, uniforms: { uTexture: { value: texture }, uResolution: { value: new THREE.Vector2(itemWidth, itemHeight) }, uImageAspect: { value: imageAspect }, uActive: { value: 1.0 }, uParallax: { value: 0.0 }, uParallaxIntensity: { value: parallaxIntensity }, uCornerRadius: { value: cornerRadius }, uDimmingFactor: { value: dimmingFactor }, }, transparent: true, depthWrite: false, }) }) }, [ textures, itemWidth, itemHeight, parallaxIntensity, cornerRadius, dimmingFactor, ]) useEffect(() => { return () => { geometry.dispose() materials.forEach((m) => m.dispose()) } }, [geometry, materials]) useEffect(() => { scrollState.current.min = 0 scrollState.current.max = images.length - 1 requestAnimationFrame(() => onReady()) }, [images.length, scrollState, onReady]) useFrame((_, delta) => { const state = scrollState.current const dt = Math.min(delta, 0.1) const diff = Math.abs(state.target - state.current) const nearest = Math.round(state.target) if (!state.isDragging && diff < 0.25) { state.target = THREE.MathUtils.damp(state.target, nearest, 2, dt) } state.target = THREE.MathUtils.clamp(state.target, state.min, state.max) state.current = THREE.MathUtils.damp( state.current, state.target, lerpFactor! * 100, dt ) if (groupRef.current) { groupRef.current.children.forEach((mesh: any, i) => { const material = materials[i] if (!material) return const dfc = i - state.current const absDfc = Math.abs(dfc) const t = Math.min(absDfc, 1.0) const easeOut = 1.0 - Math.pow(1.0 - t, 3.0) const activeGap = itemWidth * activeGapMultiplier! const stackSpacing = itemWidth * stackGapMultiplier! const xOffset = Math.sign(dfc) * (absDfc * stackSpacing + easeOut * activeGap) const zOffset = -easeOut * (itemWidth * maxZOffsetMultiplier!) - absDfc * (itemWidth * stackZOffsetMultiplier!) const yRot = Math.sign(dfc) * easeOut * -rotationMultiplier! const scale = 1.0 - easeOut * scaleMultiplier! mesh.position.set(xOffset, 0, zOffset) mesh.rotation.set(0, yRot, 0) mesh.scale.set(scale, scale, scale) mesh.renderOrder = 1000 - absDfc * 10 material.uniforms.uParallax.value = dfc material.uniforms.uActive.value = Math.max(1.0 - absDfc * 0.5, 0.0) }) } }) return ( {textures.map((_, i) => ( ))} {shadowOpacity! > 0 && ( )} ) } export const CoverCarousel = React.forwardRef< HTMLDivElement, CoverCarouselProps >( ( { images, className, cardWidthRatio = 0.35, cardAspectRatio = 1.4, scrollSensitivity = 0.003, lerpFactor = 0.06, parallaxIntensity = 0.08, activeGapMultiplier = 0.55, stackGapMultiplier = 0.15, maxZOffsetMultiplier = 0.4, stackZOffsetMultiplier = 0.02, rotationMultiplier = 0.8, scaleMultiplier = 0.15, dimmingFactor = 0.85, cornerRadius = 0.04, shadowOpacity = 0.6, ...props }, ref ) => { const containerRef = useRef(null) const [isLoaded, setIsLoaded] = useState(false) React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement) const scrollState = useRef({ target: 0, current: 0, isDragging: false, min: 0, max: 0, }) useGSAP( () => { if (!containerRef.current) return const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onPress: () => { scrollState.current.isDragging = true }, onRelease: () => { scrollState.current.isDragging = false }, onWheel: (e) => { const delta = (e.deltaX || 0) + (e.deltaY || 0) scrollState.current.target += delta * scrollSensitivity }, onDrag: (e) => { scrollState.current.target -= e.deltaX * scrollSensitivity }, onStop: () => { scrollState.current.isDragging = false }, }) return () => observer.kill() }, { scope: containerRef, dependencies: [scrollSensitivity] } ) return (

Interactive 3D Image Deck. Scroll to navigate.

{images.map((img, i) => ( {`Slide ))}
) } ) CoverCarousel.displayName = "CoverCarousel" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { CoverCarousel } from "@/registry/ui/cover-carousel" export default function ExamplePage() { const images = [ "/image1.jpg", "/image2.jpg", "/image3.jpg" ] return (
) } ``` --- # Curved Carousel Component Context **Description:** A high-performance WebGL 3D cylindrical carousel for Satis UI. Creates an immersive, spinning gallery of images utilizing custom GLSL shaders for aerodynamic bending, SDF corner rounding, and kinetic chromatic aberration. Integrates a GSAP Observer bound tightly to the container with full reduced-motion accessibility overrides for shader effects. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/curved-carousel.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------------------- | :--------- | :--------- | :----------------------------------------- | | `images` | `string[]` | _Required_ | Array of image URLs. | | `cardWidthRatio` | `number` | `0.35` | Screen width ratio. | | `cardAspectRatio` | `number` | `1.4` | Width/Height aspect ratio. | | `scrollSensitivity` | `number` | `0.005` | Scroll input multiplier. | | `lerpFactor` | `number` | `0.08` | Smooth momentum decay. | | `radiusMultiplier` | `number` | `1.2` | Carousel radius distance. | | `centrifugalMultiplier` | `number` | `0.4` | Fast-spin outward bending amount. | | `parallaxIntensity` | `number` | `0.1` | Internal texture sliding distance. | | `chromaticAberrationIntensity` | `number` | `0.004` | Kinetic color separation. | | `cornerRadius` | `number` | `0.04` | GLSL-rendered SDF corner rounding. | | `fadeMultiplier` | `number` | `1.5` | Alpha fade mapping out to the background. | | `dimmingMultiplier` | `number` | `0.8` | Shader-based ambient occlusion fake depth. | ## 3. Core Component Source **File Path:** `registry/ui/curved-carousel.tsx` ```tsx "use client" import React, { useRef, useState, useMemo, useEffect } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { useTexture } from "@react-three/drei" import * as THREE from "three" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface CurvedCarouselProps extends Omit, "children"> { images: string[] cardWidthRatio?: number cardAspectRatio?: number scrollSensitivity?: number lerpFactor?: number radiusMultiplier?: number centrifugalMultiplier?: number parallaxIntensity?: number chromaticAberrationIntensity?: number cornerRadius?: number fadeMultiplier?: number dimmingMultiplier?: number } interface ScrollState { targetAngle: number currentAngle: number velocity: number min: number max: number } const CurvedVertexShader = ` precision mediump float; uniform float uVelocity; uniform float uCentrifugalMultiplier; varying vec2 vUv; void main() { vUv = uv; vec3 pos = position; float distFromCenter = abs(uv.x - 0.5) * 2.0; float flex = pow(distFromCenter, 2.0); pos.z += flex * abs(uVelocity) * uCentrifugalMultiplier; gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } ` const CurvedFragmentShader = ` precision mediump float; uniform sampler2D uTexture; uniform float uVelocity; uniform float uDepth; uniform float uAngle; uniform vec2 uResolution; uniform float uImageAspect; uniform float uParallaxIntensity; uniform float uChromaticAberration; uniform float uCornerRadius; uniform float uFadeMultiplier; uniform float uDimmingMultiplier; varying vec2 vUv; void main() { float screenAspect = uResolution.x / uResolution.y; vec2 scale = vec2(1.0); if (screenAspect > uImageAspect) { scale.y = uImageAspect / screenAspect; } else { scale.x = screenAspect / uImageAspect; } vec2 parallaxUv = (vUv - 0.5) * (scale * 0.85) + 0.5; parallaxUv.x += clamp(uAngle, -1.0, 1.0) * uParallaxIntensity; float split = abs(uVelocity) * uChromaticAberration; float r = texture2D(uTexture, parallaxUv + vec2(split, 0.0)).r; float g = texture2D(uTexture, parallaxUv).g; float b = texture2D(uTexture, parallaxUv - vec2(split, 0.0)).b; vec4 texColor = vec4(r, g, b, 1.0); vec2 pos = vUv - 0.5; vec2 pixelPos = pos * uResolution; vec2 pixelSize = vec2(0.5) * uResolution; float pixelRadius = uCornerRadius * min(uResolution.x, uResolution.y); float dist = length(max(abs(pixelPos) - pixelSize + pixelRadius, 0.0)) - pixelRadius; float cornerAlpha = 1.0 - smoothstep(0.0, 1.5, dist); float depthFactor = smoothstep(0.0, 1.0, (uDepth + 1.0) / 2.0); float fadeAlpha = mix(0.0, 1.0, smoothstep(0.0, 1.0, depthFactor * uFadeMultiplier)); vec3 darkenedColor = mix(texColor.rgb * (1.0 - uDimmingMultiplier), texColor.rgb, depthFactor); gl_FragColor = vec4(darkenedColor, cornerAlpha * fadeAlpha); } ` function CurvedScene({ images, scrollState, onReady, cardWidthRatio, cardAspectRatio, lerpFactor, radiusMultiplier, centrifugalMultiplier, parallaxIntensity, chromaticAberrationIntensity, cornerRadius, fadeMultiplier, dimmingMultiplier, isReducedMotion, }: CurvedCarouselProps & { scrollState: React.MutableRefObject onReady: () => void isReducedMotion: boolean }) { const textures = useTexture(images) const { viewport } = useThree() const groupRef = useRef(null) const isMobile = viewport.width < 5 let itemWidth = isMobile ? viewport.width * 0.6 : viewport.width * cardWidthRatio! let itemHeight = itemWidth * cardAspectRatio! const maxHeight = viewport.height * (isMobile ? 0.6 : 0.5) if (itemHeight > maxHeight) { itemHeight = maxHeight itemWidth = itemHeight / cardAspectRatio! } const radius = viewport.width * radiusMultiplier! const angleSpacing = (itemWidth / radius) * 1.2 const geometry = useMemo( () => new THREE.PlaneGeometry(itemWidth, itemHeight, 32, 32), [itemWidth, itemHeight] ) const materials = useMemo(() => { return textures.map((texture) => { const img = texture.image as { width?: number; height?: number } | null | undefined const imageAspect = img?.width && img?.height ? img.width / img.height : 1 return new THREE.ShaderMaterial({ vertexShader: CurvedVertexShader, fragmentShader: CurvedFragmentShader, uniforms: { uTexture: { value: texture }, uVelocity: { value: 0 }, uDepth: { value: 1.0 }, uAngle: { value: 0.0 }, uResolution: { value: new THREE.Vector2(itemWidth, itemHeight) }, uImageAspect: { value: imageAspect }, uCentrifugalMultiplier: { value: isReducedMotion ? 0 : centrifugalMultiplier }, uParallaxIntensity: { value: isReducedMotion ? 0 : parallaxIntensity }, uChromaticAberration: { value: isReducedMotion ? 0 : chromaticAberrationIntensity }, uCornerRadius: { value: cornerRadius }, uFadeMultiplier: { value: fadeMultiplier }, uDimmingMultiplier: { value: dimmingMultiplier }, }, transparent: true, depthWrite: false, }) }) }, [ textures, itemWidth, itemHeight, centrifugalMultiplier, parallaxIntensity, chromaticAberrationIntensity, cornerRadius, fadeMultiplier, dimmingMultiplier, isReducedMotion, ]) useEffect(() => { return () => { geometry.dispose() materials.forEach((m) => m.dispose()) } }, [geometry, materials]) useEffect(() => { scrollState.current.min = 0 scrollState.current.max = (images.length - 1) * angleSpacing requestAnimationFrame(() => onReady()) }, [images.length, angleSpacing, scrollState, onReady]) useFrame((_, delta) => { const state = scrollState.current const dt = Math.min(delta, 0.1) state.targetAngle = THREE.MathUtils.clamp(state.targetAngle, state.min, state.max) const prevAngle = state.currentAngle state.currentAngle = THREE.MathUtils.damp( state.currentAngle, state.targetAngle, lerpFactor! * 100, dt ) const angleDelta = state.currentAngle - prevAngle const trueVelocity = angleDelta / dt state.velocity = THREE.MathUtils.damp(state.velocity, trueVelocity * 0.3, 5, dt) if (groupRef.current) { groupRef.current.children.forEach((mesh: THREE.Mesh | any, reversedIndex) => { const originalIndex = textures.length - 1 - reversedIndex const material = materials[originalIndex] if (!material) return const angle = originalIndex * angleSpacing - state.currentAngle mesh.position.x = Math.sin(angle) * radius mesh.position.z = Math.cos(angle) * radius - radius mesh.rotation.y = angle material.uniforms.uVelocity.value = state.velocity material.uniforms.uAngle.value = angle material.uniforms.uDepth.value = Math.cos(angle) }) } }) return ( {[...textures].reverse().map((_, reversedIndex) => { const originalIndex = textures.length - 1 - reversedIndex return ( ) })} ) } export const CurvedCarousel = React.forwardRef< HTMLDivElement, CurvedCarouselProps >( ( { images, className, cardWidthRatio = 0.35, cardAspectRatio = 1.4, scrollSensitivity = 0.005, lerpFactor = 0.08, radiusMultiplier = 1.2, centrifugalMultiplier = 0.4, parallaxIntensity = 0.1, chromaticAberrationIntensity = 0.004, cornerRadius = 0.04, fadeMultiplier = 1.5, dimmingMultiplier = 0.8, ...props }, ref ) => { const containerRef = useRef(null) const [isLoaded, setIsLoaded] = useState(false) const [isReducedMotion, setIsReducedMotion] = useState(false) React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement) const scrollState = useRef({ targetAngle: 0, currentAngle: 0, velocity: 0, min: 0, max: 0, }) useGSAP( () => { if (!containerRef.current) return const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)") setIsReducedMotion(mediaQuery.matches) const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onWheel: (e) => { const delta = (e.deltaX || 0) + (e.deltaY || 0) scrollState.current.targetAngle += delta * scrollSensitivity }, onDrag: (e) => { scrollState.current.targetAngle -= e.deltaX * scrollSensitivity }, }) return () => observer.kill() }, { scope: containerRef, dependencies: [scrollSensitivity] } ) return (

Interactive 3D Curved Carousel. Scroll or swipe to navigate.

) } ) CurvedCarousel.displayName = "CurvedCarousel" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { CurvedCarousel } from "@/registry/ui/curved-carousel" export default function ExamplePage() { const images = [ "/image1.jpg", "/image2.jpg", "/image3.jpg" ] return (
) } ``` --- # Depth Carousel Component Context **Description:** A high-performance WebGL coverflow carousel for Satis UI. Unites physics-based GSAP scroll tracking with custom GLSL shaders to provide flawless SDF corner rounding, internal parallax windowing, and kinetic RGB splitting. Features strict viewport clamping logic to ensure mobile responsiveness without breaking spatial rendering bounds. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/depth-carousel.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------------------- | :--------- | :--------- | :------------------------- | | `images` | `string[]` | _Required_ | Array of image URLs. | | `cardWidthRatio` | `number` | `0.35` | Screen width ratio. | | `cardAspectRatio` | `number` | `1.4` | Width/Height aspect ratio. | | `gapMultiplier` | `number` | `0.7` | Background curve spread. | | `activeGapMultiplier` | `number` | `0.25` | Active center buffer. | | `scrollSensitivity` | `number` | `0.003` | Scroll input multiplier. | | `lerpFactor` | `number` | `0.06` | Smooth momentum decay. | | `depthMultiplier` | `number` | `0.25` | Z-axis push multiplier. | | `scaleMultiplier` | `number` | `0.15` | Diminishing scale curve. | | `rotationMultiplier` | `number` | `0.1` | Inward tilt of cards. | | `parallaxIntensity` | `number` | `0.08` | Internal UV shift power. | | `dimmingMultiplier` | `number` | `0.85` | Background darkening. | | `chromaticAberrationIntensity` | `number` | `0.01` | RGB split on scroll. | | `cornerRadius` | `number` | `0.03` | SDF border rounding. | | `shadowOpacity` | `number` | `0.6` | Floor contact shadow. | ## 3. Core Component Source **File Path:** `registry/ui/depth-carousel.tsx` ```tsx "use client" import React, { useRef, useState, useMemo, useEffect } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { useTexture, ContactShadows } from "@react-three/drei" import * as THREE from "three" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface DepthCarouselProps extends Omit< React.HTMLAttributes, "children" > { images: string[] cardWidthRatio?: number cardAspectRatio?: number gapMultiplier?: number activeGapMultiplier?: number scrollSensitivity?: number lerpFactor?: number depthMultiplier?: number scaleMultiplier?: number rotationMultiplier?: number parallaxIntensity?: number chromaticAberrationIntensity?: number dimmingMultiplier?: number cornerRadius?: number shadowOpacity?: number } interface ScrollState { target: number current: number velocity: number isDragging: boolean min: number max: number } const DepthVertexShader = ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` const DepthFragmentShader = ` precision mediump float; uniform sampler2D uTexture; uniform vec2 uResolution; uniform float uImageAspect; uniform float uActive; uniform float uVelocity; uniform float uParallax; uniform float uParallaxIntensity; uniform float uChromaticAberrationIntensity; uniform float uCornerRadius; uniform float uDimmingMultiplier; varying vec2 vUv; void main() { float screenAspect = uResolution.x / uResolution.y; vec2 scale = vec2(1.0); if (screenAspect > uImageAspect) { scale.y = uImageAspect / screenAspect; } else { scale.x = screenAspect / uImageAspect; } vec2 parallaxUv = (vUv - 0.5) * (scale * 0.85) + 0.5; parallaxUv.x += clamp(uParallax, -1.0, 1.0) * uParallaxIntensity; float split = abs(uVelocity) * uChromaticAberrationIntensity; float r = texture2D(uTexture, parallaxUv + vec2(split, 0.0)).r; float g = texture2D(uTexture, parallaxUv).g; float b = texture2D(uTexture, parallaxUv - vec2(split, 0.0)).b; vec3 texColor = vec3(r, g, b); vec2 pos = vUv - 0.5; vec2 pixelPos = pos * uResolution; vec2 pixelSize = vec2(0.5) * uResolution; float pixelRadius = uCornerRadius * min(uResolution.x, uResolution.y); float dist = length(max(abs(pixelPos) - pixelSize + pixelRadius, 0.0)) - pixelRadius; float alpha = 1.0 - smoothstep(0.0, 1.5, dist); float dimBaseline = 1.0 - uDimmingMultiplier; vec3 color = mix(texColor * dimBaseline, texColor, uActive); gl_FragColor = vec4(color, alpha); } ` function DepthScene({ images, scrollState, onReady, cardWidthRatio, cardAspectRatio, lerpFactor, gapMultiplier, activeGapMultiplier, depthMultiplier, scaleMultiplier, rotationMultiplier, parallaxIntensity, chromaticAberrationIntensity, dimmingMultiplier, cornerRadius, shadowOpacity, }: DepthCarouselProps & { scrollState: React.MutableRefObject onReady: () => void }) { const textures = useTexture(images) const { viewport } = useThree() const groupRef = useRef(null) const isMobile = viewport.width < 5 let itemWidth = isMobile ? viewport.width * 0.65 : viewport.width * cardWidthRatio! let itemHeight = itemWidth * cardAspectRatio! const maxHeight = viewport.height * (isMobile ? 0.6 : 0.65) if (itemHeight > maxHeight) { itemHeight = maxHeight itemWidth = itemHeight / cardAspectRatio! } const geometry = useMemo( () => new THREE.PlaneGeometry(itemWidth, itemHeight, 1, 1), [itemWidth, itemHeight] ) const materials = useMemo(() => { return textures.map((texture) => { const img = texture.image as | { width?: number; height?: number } | null | undefined const imageAspect = img?.width && img?.height ? img.width / img.height : 1 return new THREE.ShaderMaterial({ vertexShader: DepthVertexShader, fragmentShader: DepthFragmentShader, uniforms: { uTexture: { value: texture }, uVelocity: { value: 0 }, uResolution: { value: new THREE.Vector2(itemWidth, itemHeight) }, uImageAspect: { value: imageAspect }, uParallax: { value: 0 }, uActive: { value: 1.0 }, uParallaxIntensity: { value: parallaxIntensity }, uChromaticAberrationIntensity: { value: chromaticAberrationIntensity, }, uCornerRadius: { value: cornerRadius }, uDimmingMultiplier: { value: dimmingMultiplier }, }, transparent: true, depthWrite: false, }) }) }, [ textures, itemWidth, itemHeight, parallaxIntensity, chromaticAberrationIntensity, cornerRadius, dimmingMultiplier, ]) useEffect(() => { return () => { geometry.dispose() materials.forEach((m) => m.dispose()) } }, [geometry, materials]) useEffect(() => { scrollState.current.min = 0 scrollState.current.max = images.length - 1 requestAnimationFrame(() => onReady()) }, [images.length, scrollState, onReady]) useFrame((_, delta) => { const state = scrollState.current const dt = Math.min(delta, 0.1) const prev = state.current const diff = Math.abs(state.target - state.current) const nearest = Math.round(state.target) if (!state.isDragging && diff < 0.25) { state.target = THREE.MathUtils.damp(state.target, nearest, 2, dt) } state.target = THREE.MathUtils.clamp(state.target, state.min, state.max) state.current = THREE.MathUtils.damp( state.current, state.target, lerpFactor! * 100, dt ) const rawVelocity = (state.current - prev) / dt state.velocity = THREE.MathUtils.damp(state.velocity, rawVelocity, 5, dt) if (groupRef.current) { groupRef.current.children.forEach((mesh: any, i) => { const material = materials[i] if (!material) return const dfc = i - state.current const absDfc = Math.abs(dfc) let xOffset = Math.sign(dfc) * itemWidth * gapMultiplier! * Math.pow(absDfc, 0.8) const activeBuffer = Math.sign(dfc) * Math.min(absDfc, 1.0) * (itemWidth * activeGapMultiplier!) xOffset += activeBuffer const zOffset = -absDfc * (itemWidth * depthMultiplier!) const scale = Math.max(1.0 - absDfc * scaleMultiplier!, 0.5) const yRot = -dfc * rotationMultiplier! mesh.position.set(xOffset, 0, zOffset) mesh.rotation.set(0, yRot, 0) mesh.scale.set(scale, scale, scale) mesh.renderOrder = 1000 - absDfc * 10 material.uniforms.uVelocity.value = state.velocity material.uniforms.uParallax.value = dfc material.uniforms.uActive.value = Math.max(1.0 - absDfc * 0.6, 0.0) }) } }) return ( {textures.map((_, i) => ( ))} {shadowOpacity! > 0 && ( )} ) } export const DepthCarousel = React.forwardRef< HTMLDivElement, DepthCarouselProps >( ( { images, className, cardWidthRatio = 0.35, cardAspectRatio = 1.4, gapMultiplier = 0.7, activeGapMultiplier = 0.25, depthMultiplier = 0.25, scaleMultiplier = 0.15, rotationMultiplier = 0.1, scrollSensitivity = 0.003, lerpFactor = 0.06, parallaxIntensity = 0.08, chromaticAberrationIntensity = 0.01, dimmingMultiplier = 0.85, cornerRadius = 0.03, shadowOpacity = 0.6, ...props }, ref ) => { const containerRef = useRef(null) const [isLoaded, setIsLoaded] = useState(false) React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement) const scrollState = useRef({ target: 0, current: 0, velocity: 0, isDragging: false, min: 0, max: 0, }) useGSAP( () => { if (!containerRef.current) return const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onPress: () => { scrollState.current.isDragging = true }, onRelease: () => { scrollState.current.isDragging = false }, onWheel: (e) => { const delta = (e.deltaX || 0) + (e.deltaY || 0) scrollState.current.target += delta * scrollSensitivity }, onDrag: (e) => { scrollState.current.target -= e.deltaX * scrollSensitivity }, onStop: () => { scrollState.current.isDragging = false }, }) return () => observer.kill() }, { scope: containerRef, dependencies: [scrollSensitivity] } ) return (

Interactive Premium Depth Carousel. Scroll or swipe to navigate.

) } ) DepthCarousel.displayName = "DepthCarousel" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { DepthCarousel } from "@/registry/ui/depth-carousel" export default function ExamplePage() { const images = [ "/image1.jpg", "/image2.jpg", "/image3.jpg" ] return (
) } ``` --- # Depth Trail Component Context **Description:** A cinematic mouse trail leveraging Z-depth mapping, focal blur, parallax shifting, and atmospheric lighting to simulate a rich, dense 3D space. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/depth-trail.json ``` **Dependencies installed:** `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :-------------- | :--------- | :------ | :--------------------------------------------------------------------- | | `imageUrls` | `string[]` | `[]` | Array of image URLs to randomly spawn. | | `distance` | `number` | `40` | Distance the pointer must move (in pixels) before spawning a new item. | | `duration` | `number` | `2000` | The total lifespan of a spawned item in milliseconds. | | `maxItems` | `number` | `20` | Maximum items on screen before forcing the oldest to fade out. | | `itemSize` | `number` | `90` | The base pixel size of the image cards. | | `rotationRange` | `number` | `25` | Maximum random rotation (in degrees) applied to the cards. | | `className` | `string` | `""` | Optional standard Tailwind classes for the wrapper. | | `itemClassName` | `string` | `""` | Optional standard Tailwind classes for the individual image cards. | ## 3. Core Component Source **File Path:** `registry/ui/depth-trail.tsx` ```tsx "use client" import { useEffect, useRef } from "react" import { cn } from "@/lib/utils" export interface DepthTrailProps { imageUrls: string[] distance?: number duration?: number maxItems?: number itemSize?: number rotationRange?: number className?: string itemClassName?: string } interface TileData { active: boolean baseX: number baseY: number zDepth: number rotation: number imageIndex: number t: number state: "enter" | "hold" | "exit" holdTime: number spawnTime: number } export default function DepthTrail({ imageUrls, distance = 40, duration = 2000, maxItems = 20, itemSize = 90, rotationRange = 25, className, itemClassName = "", }: DepthTrailProps) { const reqRef = useRef(null) const DOM_POOL_SIZE = maxItems * 3 const pool = useRef( Array.from({ length: DOM_POOL_SIZE }, () => ({ active: false, baseX: 0, baseY: 0, zDepth: 0, rotation: 0, imageIndex: 0, t: 0, state: "enter", holdTime: 0, spawnTime: 0, })) ) const domRefs = useRef<(HTMLDivElement | null)[]>([]) const state = useRef({ lastDropPos: { x: -1000, y: -1000 }, currentMouse: { x: -1000, y: -1000 }, spawnCount: 0, lastFrameTime: 0, }) const config = useRef({ imageUrls, distance, maxItems, duration, itemSize, rotationRange }) useEffect(() => { config.current = { imageUrls, distance, maxItems, duration, itemSize, rotationRange } }, [imageUrls, distance, maxItems, duration, itemSize, rotationRange]) useEffect(() => { state.current.currentMouse = { x: window.innerWidth / 2, y: window.innerHeight / 2, } const handlePointerMove = (e: PointerEvent) => { const s = state.current const c = config.current s.currentMouse = { x: e.clientX, y: e.clientY } const dy = e.clientY - s.lastDropPos.y const dx = e.clientX - s.lastDropPos.x const moveDist = Math.hypot(dx, dy) if (moveDist >= c.distance) { const activeNonExiting = pool.current.filter( (p) => p.active && p.state !== "exit" ) if (activeNonExiting.length >= c.maxItems) { activeNonExiting.sort((a, b) => a.spawnTime - b.spawnTime) const oldest = activeNonExiting[0] oldest.state = "exit" } const freeIndex = pool.current.findIndex((p) => !p.active) if (freeIndex !== -1) { s.lastDropPos = { x: e.clientX, y: e.clientY } s.spawnCount += 1 const rawDepth = Math.random() const zDepth = Math.pow(rawDepth, 1.2) pool.current[freeIndex] = { active: true, baseX: e.clientX, baseY: e.clientY, zDepth, rotation: (Math.random() - 0.5) * (c.rotationRange * 2), imageIndex: s.spawnCount % c.imageUrls.length, t: 0, state: "enter", holdTime: 0, spawnTime: Date.now(), } } } } window.addEventListener("pointermove", handlePointerMove) return () => window.removeEventListener("pointermove", handlePointerMove) }, []) useEffect(() => { state.current.lastFrameTime = Date.now() const animate = () => { const c = config.current const currentTime = Date.now() const delta = Math.min(currentTime - state.current.lastFrameTime, 32) state.current.lastFrameTime = currentTime const enterDuration = c.duration * 0.15 const holdDuration = c.duration * 0.55 const exitDuration = c.duration * 0.3 const centerX = window.innerWidth / 2 const centerY = window.innerHeight / 2 const mouseOffsetX = state.current.currentMouse.x - centerX const mouseOffsetY = state.current.currentMouse.y - centerY for (let i = 0; i < DOM_POOL_SIZE; i++) { const item = pool.current[i] const domNode = domRefs.current[i] if (!domNode) continue if (!item.active) { domNode.style.display = "none" continue } if (item.state === "enter") { item.t += delta / enterDuration if (item.t >= 1) { item.t = 1 item.state = "hold" } } else if (item.state === "hold") { item.holdTime += delta if (item.holdTime >= holdDuration) { item.state = "exit" } } else if (item.state === "exit") { item.t -= delta / exitDuration if (item.t <= 0) { item.t = 0 item.active = false domNode.style.display = "none" continue } } const isEnter = item.state === "enter" const baseScale = isEnter ? 1 - Math.pow(1 - item.t, 3) : item.t * item.t const opacity = item.t const depthScale = 0.35 + item.zDepth * 1.45 const finalScale = baseScale * depthScale let blurAmount = 0 if (item.zDepth > 0.8) { blurAmount = (item.zDepth - 0.8) * 40 } else if (item.zDepth < 0.4) { blurAmount = (0.4 - item.zDepth) * 15 } const brightness = 0.3 + item.zDepth * 0.7 const parallaxX = mouseOffsetX * (item.zDepth * -0.15) const parallaxY = mouseOffsetY * (item.zDepth * -0.15) const timeAlive = currentTime - item.spawnTime const driftY = timeAlive * (0.02 + item.zDepth * 0.03) * -1 const x = item.baseX + parallaxX const y = item.baseY + parallaxY + driftY domNode.style.display = "flex" domNode.style.zIndex = Math.floor(item.zDepth * 100).toString() domNode.style.opacity = opacity.toString() domNode.style.filter = `blur(${blurAmount}px) brightness(${brightness})` domNode.style.transform = ` translate3d(${x}px, ${y}px, 0) rotate(${item.rotation}deg) scale(${finalScale}) ` const imagesInside = domNode.querySelectorAll("img") imagesInside.forEach((img, idx) => { img.style.display = idx === item.imageIndex ? "block" : "none" }) } reqRef.current = requestAnimationFrame(animate) } reqRef.current = requestAnimationFrame(animate) return () => { if (reqRef.current) cancelAnimationFrame(reqRef.current) } }, [DOM_POOL_SIZE]) useEffect(() => { imageUrls.forEach((src) => { const img = new Image() img.crossOrigin = "anonymous" img.referrerPolicy = "no-referrer" img.src = src }) }, [imageUrls]) return (
{Array.from({ length: DOM_POOL_SIZE }).map((_, i) => (
{ domRefs.current[i] = el }} className={cn( "absolute top-0 left-0 overflow-hidden bg-transparent p-0 drop-shadow-2xl will-change-transform", itemClassName )} style={{ width: `${itemSize}px`, height: `${itemSize}px`, marginLeft: `-${itemSize / 2}px`, marginTop: `-${itemSize / 2}px`, borderRadius: "22%", display: "none", willChange: "transform, filter, opacity" }} > {imageUrls.map((src, imgIndex) => ( trail ))}
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import DepthTrail from "@/registry/ui/depth-trail" const trailImages = [ "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1780746659/ui-v3/avatars/color/16.png", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1780746659/ui-v3/avatars/color/17.png", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1780746659/ui-v3/avatars/color/18.png", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1780746659/ui-v3/avatars/color/19.png", ] export default function ExamplePage() { return (

Cinematic Depth.

) } ``` --- # Dimensional Carousel Component Context **Description:** A high-performance WebGL horizontal carousel for Satis UI. Cards arriving from the right are flat, while cards passing the center seamlessly stack into a 3D Cover Flow layout. Features custom GLSL shaders for aerodynamic bending and kinetic RGB splitting based on swipe velocity. Implements GSAP Observer, strict container binding, dynamic mobile scaling, and WCAG vestibular failsafes. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/dimensional-carousel.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------------------- | :--------- | :--------- | :--------------------------------- | | `images` | `string[]` | _Required_ | Array of image URLs. | | `cardWidthRatio` | `number` | `0.35` | Screen width ratio. | | `cardAspectRatio` | `number` | `1.4` | Width/Height aspect ratio. | | `gapMultiplier` | `number` | `0.8` | Incoming flat spacing multiplier. | | `scrollSensitivity` | `number` | `0.04` | Scroll input multiplier. | | `lerpFactor` | `number` | `0.08` | Smooth momentum decay. | | `stackGapMultiplier` | `number` | `0.1` | Z-stacking tightness. | | `depthMultiplier` | `number` | `0.8` | Z-axis push multiplier. | | `rotationMultiplier` | `number` | `0.08` | Y-axis tilt multiplier. | | `flexMultiplier` | `number` | `0.12` | Paper aerodynamic flex. | | `parallaxIntensity` | `number` | `0.1` | Internal texture sliding distance. | | `chromaticAberrationIntensity` | `number` | `0.005` | Kinetic color separation. | | `dimmingMultiplier` | `number` | `0.6` | Shader-based fake depth occlusion. | | `cornerRadius` | `number` | `0.04` | GLSL-rendered SDF corner rounding. | ## 3. Core Component Source **File Path:** `registry/ui/dimensional-carousel.tsx` ```tsx "use client" import React, { useRef, useState, useMemo, useEffect } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { useTexture } from "@react-three/drei" import * as THREE from "three" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface DimensionalCarouselProps extends Omit< React.HTMLAttributes, "children" > { images: string[] cardWidthRatio?: number cardAspectRatio?: number gapMultiplier?: number scrollSensitivity?: number lerpFactor?: number stackGapMultiplier?: number depthMultiplier?: number rotationMultiplier?: number flexMultiplier?: number parallaxIntensity?: number chromaticAberrationIntensity?: number dimmingMultiplier?: number cornerRadius?: number } interface ScrollState { targetX: number currentX: number velocity: number min: number max: number } const DimensionalVertexShader = ` precision mediump float; uniform float uVelocity; uniform float uFlexMultiplier; varying vec2 vUv; void main() { vUv = uv; vec3 pos = position; float curve = sin(uv.x * 3.14159); pos.z -= curve * uVelocity * uFlexMultiplier; pos.x += curve * abs(uVelocity) * (uFlexMultiplier * 0.4); gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } ` const DimensionalFragmentShader = ` precision mediump float; uniform sampler2D uTexture; uniform float uVelocity; uniform float uStackDepth; uniform vec2 uResolution; uniform float uImageAspect; uniform float uChromaticAberrationIntensity; uniform float uParallaxIntensity; uniform float uCornerRadius; uniform float uDimmingMultiplier; varying vec2 vUv; void main() { float screenAspect = uResolution.x / uResolution.y; vec2 scale = vec2(1.0); if (screenAspect > uImageAspect) { scale.y = uImageAspect / screenAspect; } else { scale.x = screenAspect / uImageAspect; } vec2 parallaxUv = (vUv - 0.5) * (scale * 0.85) + 0.5; parallaxUv.x += clamp(uStackDepth * 0.1, -1.0, 1.0) * uParallaxIntensity; float split = abs(uVelocity) * uChromaticAberrationIntensity; float r = texture2D(uTexture, parallaxUv + vec2(split, 0.0)).r; float g = texture2D(uTexture, parallaxUv).g; float b = texture2D(uTexture, parallaxUv - vec2(split, 0.0)).b; vec3 texColor = vec3(r, g, b); vec2 pos = vUv - 0.5; vec2 pixelPos = pos * uResolution; vec2 pixelSize = vec2(0.5) * uResolution; float pixelRadius = uCornerRadius * min(uResolution.x, uResolution.y); float dist = length(max(abs(pixelPos) - pixelSize + pixelRadius, 0.0)) - pixelRadius; float cornerAlpha = 1.0 - smoothstep(0.0, 1.5, dist); float shadow = smoothstep(0.0, 4.0, uStackDepth) * uDimmingMultiplier; vec3 darkenedColor = mix(texColor, vec3(0.0), shadow); float fadeAlpha = 1.0 - smoothstep(3.0, 7.0, uStackDepth); gl_FragColor = vec4(darkenedColor, cornerAlpha * fadeAlpha); } ` function CarouselScene({ images, scrollState, onReady, cardWidthRatio, cardAspectRatio, lerpFactor, gapMultiplier, stackGapMultiplier, depthMultiplier, parallaxIntensity, chromaticAberrationIntensity, flexMultiplier, rotationMultiplier, dimmingMultiplier, cornerRadius, isReducedMotion, }: DimensionalCarouselProps & { scrollState: React.MutableRefObject onReady: () => void isReducedMotion: boolean }) { const textures = useTexture(images) const { viewport } = useThree() const groupRef = useRef(null) const isMobile = viewport.width < 5 let itemWidth = isMobile ? viewport.width * 0.6 : viewport.width * cardWidthRatio! let itemHeight = itemWidth * cardAspectRatio! const maxHeight = viewport.height * (isMobile ? 0.6 : 0.5) if (itemHeight > maxHeight) { itemHeight = maxHeight itemWidth = itemHeight / cardAspectRatio! } const spacing = viewport.width * gapMultiplier! const geometry = useMemo( () => new THREE.PlaneGeometry(itemWidth, itemHeight, 32, 32), [itemWidth, itemHeight] ) const materials = useMemo(() => { return textures.map((texture) => { const img = texture.image as | { width?: number; height?: number } | null | undefined const imageAspect = img?.width && img?.height ? img.width / img.height : 1 return new THREE.ShaderMaterial({ vertexShader: DimensionalVertexShader, fragmentShader: DimensionalFragmentShader, uniforms: { uTexture: { value: texture }, uVelocity: { value: 0 }, uStackDepth: { value: 0 }, uResolution: { value: new THREE.Vector2(itemWidth, itemHeight) }, uImageAspect: { value: imageAspect }, uFlexMultiplier: { value: isReducedMotion ? 0 : flexMultiplier }, uChromaticAberrationIntensity: { value: isReducedMotion ? 0 : chromaticAberrationIntensity }, uParallaxIntensity: { value: isReducedMotion ? 0 : parallaxIntensity }, uDimmingMultiplier: { value: dimmingMultiplier }, uCornerRadius: { value: cornerRadius }, }, transparent: true, depthWrite: false, }) }) }, [ textures, itemWidth, itemHeight, flexMultiplier, chromaticAberrationIntensity, parallaxIntensity, dimmingMultiplier, cornerRadius, isReducedMotion, ]) useEffect(() => { return () => { geometry.dispose() materials.forEach((m) => m.dispose()) } }, [geometry, materials]) useEffect(() => { scrollState.current.min = 0 scrollState.current.max = (images.length - 1) * spacing requestAnimationFrame(() => onReady()) }, [images.length, spacing, scrollState, onReady]) useFrame((_, delta) => { const state = scrollState.current const dt = Math.min(delta, 0.1) state.targetX = THREE.MathUtils.clamp(state.targetX, state.min, state.max) const prevX = state.currentX state.currentX = THREE.MathUtils.damp( state.currentX, state.targetX, lerpFactor! * 100, dt ) const rawVelocity = (state.currentX - prevX) / dt state.velocity = THREE.MathUtils.damp( state.velocity, rawVelocity * 0.15, 5, dt ) if (groupRef.current) { groupRef.current.children.forEach((mesh: THREE.Mesh | any, i) => { const material = materials[i] if (!material) return const relativeX = i * spacing - state.currentX let x, z, rotY let stackDepth = 0 if (relativeX > 0) { x = relativeX z = 0 rotY = 0 } else { x = relativeX * stackGapMultiplier! z = relativeX * depthMultiplier! rotY = relativeX * rotationMultiplier! stackDepth = Math.abs(relativeX) } mesh.position.set(x, 0, z) mesh.rotation.set(0, rotY, 0) mesh.renderOrder = 1000 - Math.abs(relativeX) material.uniforms.uVelocity.value = state.velocity material.uniforms.uStackDepth.value = stackDepth }) } }) return ( {textures.map((_, i) => ( ))} ) } export const DimensionalCarousel = React.forwardRef< HTMLDivElement, DimensionalCarouselProps >( ( { images, className, cardWidthRatio = 0.35, cardAspectRatio = 1.4, scrollSensitivity = 0.04, lerpFactor = 0.08, gapMultiplier = 0.8, stackGapMultiplier = 0.1, depthMultiplier = 0.8, parallaxIntensity = 0.1, chromaticAberrationIntensity = 0.005, flexMultiplier = 0.12, rotationMultiplier = 0.08, dimmingMultiplier = 0.6, cornerRadius = 0.04, ...props }, ref ) => { const containerRef = useRef(null) const [isLoaded, setIsLoaded] = useState(false) const [isReducedMotion, setIsReducedMotion] = useState(false) React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement) const scrollState = useRef({ targetX: 0, currentX: 0, velocity: 0, min: 0, max: 0, }) useGSAP( () => { if (!containerRef.current) return const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)") setIsReducedMotion(mediaQuery.matches) const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onWheel: (e) => { const state = scrollState.current const delta = (e.deltaX || 0) + (e.deltaY || 0) const isAtLeft = state.targetX <= state.min && delta < 0 const isAtRight = state.targetX >= state.max && delta > 0 if (!isAtLeft && !isAtRight) { state.targetX += delta * scrollSensitivity } }, onDrag: (e) => { const state = scrollState.current const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? -e.deltaX : -e.deltaY const isAtLeft = state.targetX <= state.min && delta < 0 const isAtRight = state.targetX >= state.max && delta > 0 if (!isAtLeft && !isAtRight) { state.targetX += delta * scrollSensitivity } }, }) return () => observer.kill() }, { scope: containerRef, dependencies: [scrollSensitivity] } ) return (

Interactive 3D Carousel. Scroll or swipe to navigate.

{images.map((img, i) => ( {`Slide ))}
) } ) DimensionalCarousel.displayName = "DimensionalCarousel" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { DimensionalCarousel } from "@/registry/ui/dimensional-carousel" export default function ExamplePage() { const images = [ "/image1.jpg", "/image2.jpg", "/image3.jpg" ] return (
) } ``` --- # Dimensional Deck Component Context **Description:** A high-performance WebGL scroll component for Satis UI. Creates an immersive, 3D stacked deck of images that respond fluidly to vertical scroll momentum anywhere on the container. Utilizes custom GLSL shaders for kinetic RGB splitting and aerodynamic bending. Implements GSAP Observer, strict container binding, responsive clamping, and proper ARIA visual masking. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/dimensional-deck.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------------------- | :--------- | :--------- | :------------------------------------------- | | `images` | `string[]` | _Required_ | Array of image URLs. | | `cardWidthRatio` | `number` | `0.35` | Screen width ratio. | | `cardAspectRatio` | `number` | `1.4` | Width/Height aspect ratio. | | `gapMultiplier` | `number` | `1.2` | Y-axis spacing multiplier. | | `stackGapMultiplier` | `number` | `0.1` | Friction zone Y-axis overlap. | | `depthMultiplier` | `number` | `0.8` | Friction zone Z-axis depth mapping. | | `flexMultiplier` | `number` | `0.15` | Aerodynamic paper bending vertex distortion. | | `rotationMultiplier` | `number` | `0.08` | Friction zone X-axis resting tilt. | | `scrollSensitivity` | `number` | `0.04` | Scroll input multiplier. | | `lerpFactor` | `number` | `0.08` | Smooth momentum decay. | | `parallaxIntensity` | `number` | `0.1` | Internal texture sliding distance. | | `chromaticAberrationIntensity` | `number` | `0.005` | Kinetic color separation. | | `dimmingMultiplier` | `number` | `0.6` | Shader-based ambient occlusion fake depth. | | `cornerRadius` | `number` | `0.04` | GLSL-rendered SDF corner rounding. | ## 3. Core Component Source **File Path:** `registry/ui/dimensional-deck.tsx` ```tsx "use client" import React, { useRef, useState, useMemo, useEffect } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { useTexture } from "@react-three/drei" import * as THREE from "three" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface DimensionalDeckProps extends Omit< React.HTMLAttributes, "children" > { images: string[] cardWidthRatio?: number cardAspectRatio?: number gapMultiplier?: number scrollSensitivity?: number lerpFactor?: number stackGapMultiplier?: number depthMultiplier?: number flexMultiplier?: number rotationMultiplier?: number parallaxIntensity?: number chromaticAberrationIntensity?: number dimmingMultiplier?: number cornerRadius?: number } interface ScrollState { targetY: number currentY: number velocity: number min: number max: number } const DeckVertexShader = ` precision mediump float; uniform float uVelocity; uniform float uFlexMultiplier; varying vec2 vUv; void main() { vUv = uv; vec3 pos = position; float curve = sin(uv.y * 3.14159); pos.z -= curve * uVelocity * uFlexMultiplier; pos.y += curve * abs(uVelocity) * (uFlexMultiplier * 0.4); gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } ` const DeckFragmentShader = ` precision mediump float; uniform sampler2D uTexture; uniform float uVelocity; uniform float uStackDepth; uniform vec2 uResolution; uniform float uImageAspect; uniform float uChromaticAberrationIntensity; uniform float uParallaxIntensity; uniform float uCornerRadius; uniform float uDimmingMultiplier; varying vec2 vUv; void main() { float screenAspect = uResolution.x / uResolution.y; vec2 scale = vec2(1.0); if (screenAspect > uImageAspect) { scale.y = uImageAspect / screenAspect; } else { scale.x = screenAspect / uImageAspect; } vec2 parallaxUv = (vUv - 0.5) * (scale * 0.85) + 0.5; parallaxUv.y += clamp(uStackDepth * 0.1, -1.0, 1.0) * uParallaxIntensity; float split = abs(uVelocity) * uChromaticAberrationIntensity; float r = texture2D(uTexture, parallaxUv + vec2(0.0, split)).r; float g = texture2D(uTexture, parallaxUv).g; float b = texture2D(uTexture, parallaxUv - vec2(0.0, split)).b; vec3 texColor = vec3(r, g, b); vec2 pos = vUv - 0.5; vec2 pixelPos = pos * uResolution; vec2 pixelSize = vec2(0.5) * uResolution; float pixelRadius = uCornerRadius * min(uResolution.x, uResolution.y); float dist = length(max(abs(pixelPos) - pixelSize + pixelRadius, 0.0)) - pixelRadius; float cornerAlpha = 1.0 - smoothstep(0.0, 1.5, dist); float shadow = smoothstep(0.0, 4.0, uStackDepth) * uDimmingMultiplier; vec3 darkenedColor = mix(texColor, vec3(0.0), shadow); float fadeAlpha = 1.0 - smoothstep(3.0, 7.0, uStackDepth); gl_FragColor = vec4(darkenedColor, cornerAlpha * fadeAlpha); } ` function DeckScene({ images, scrollState, onReady, cardWidthRatio, cardAspectRatio, lerpFactor, gapMultiplier, stackGapMultiplier, depthMultiplier, flexMultiplier, rotationMultiplier, parallaxIntensity, chromaticAberrationIntensity, dimmingMultiplier, cornerRadius, isReducedMotion, }: DimensionalDeckProps & { scrollState: React.MutableRefObject onReady: () => void isReducedMotion: boolean }) { const textures = useTexture(images) const { viewport } = useThree() const groupRef = useRef(null) const isMobile = viewport.width < 5 let itemWidth = isMobile ? viewport.width * 0.6 : viewport.width * cardWidthRatio! let itemHeight = itemWidth * cardAspectRatio! const maxHeight = viewport.height * (isMobile ? 0.6 : 0.5) if (itemHeight > maxHeight) { itemHeight = maxHeight itemWidth = itemHeight / cardAspectRatio! } const spacing = viewport.height * gapMultiplier! const geometry = useMemo( () => new THREE.PlaneGeometry(itemWidth, itemHeight, 32, 32), [itemWidth, itemHeight] ) const materials = useMemo(() => { return textures.map((texture) => { const img = texture.image as { width?: number; height?: number } | null | undefined const imageAspect = img?.width && img?.height ? img.width / img.height : 1 return new THREE.ShaderMaterial({ vertexShader: DeckVertexShader, fragmentShader: DeckFragmentShader, uniforms: { uTexture: { value: texture }, uVelocity: { value: 0 }, uStackDepth: { value: 0 }, uResolution: { value: new THREE.Vector2(itemWidth, itemHeight) }, uImageAspect: { value: imageAspect }, uCornerRadius: { value: cornerRadius }, uDimmingMultiplier: { value: dimmingMultiplier }, uFlexMultiplier: { value: isReducedMotion ? 0 : flexMultiplier }, uParallaxIntensity: { value: isReducedMotion ? 0 : parallaxIntensity }, uChromaticAberrationIntensity: { value: isReducedMotion ? 0 : chromaticAberrationIntensity }, }, transparent: true, depthWrite: false, }) }) }, [ textures, itemWidth, itemHeight, flexMultiplier, chromaticAberrationIntensity, parallaxIntensity, dimmingMultiplier, cornerRadius, isReducedMotion, ]) useEffect(() => { return () => { geometry.dispose() materials.forEach((m) => m.dispose()) } }, [geometry, materials]) useEffect(() => { scrollState.current.min = 0 scrollState.current.max = (images.length - 1) * spacing requestAnimationFrame(() => onReady()) }, [images.length, spacing, scrollState, onReady]) useFrame((_, delta) => { const state = scrollState.current const dt = Math.min(delta, 0.1) state.targetY = THREE.MathUtils.clamp(state.targetY, state.min, state.max) const prevY = state.currentY state.currentY = THREE.MathUtils.damp( state.currentY, state.targetY, lerpFactor! * 100, dt ) const rawVelocity = (state.currentY - prevY) / dt state.velocity = THREE.MathUtils.damp( state.velocity, rawVelocity * 0.15, 5, dt ) if (groupRef.current) { groupRef.current.children.forEach((mesh: THREE.Mesh | any, i) => { const material = materials[i] if (!material) return const relativeY = i * spacing - state.currentY let y, z, rotX let stackDepth = 0 if (relativeY > 0) { y = -relativeY z = 0 rotX = 0 } else { y = relativeY * stackGapMultiplier! z = relativeY * depthMultiplier! rotX = relativeY * rotationMultiplier! stackDepth = Math.abs(relativeY) } mesh.position.set(0, y, z) mesh.rotation.set(rotX, 0, 0) mesh.renderOrder = 1000 - Math.abs(relativeY) material.uniforms.uVelocity.value = state.velocity material.uniforms.uStackDepth.value = stackDepth }) } }) return ( {textures.map((_, i) => ( ))} ) } export const DimensionalDeck = React.forwardRef< HTMLDivElement, DimensionalDeckProps >( ( { images, className, cardWidthRatio = 0.35, cardAspectRatio = 1.4, scrollSensitivity = 0.04, lerpFactor = 0.08, gapMultiplier = 1.2, stackGapMultiplier = 0.1, depthMultiplier = 0.8, parallaxIntensity = 0.1, chromaticAberrationIntensity = 0.005, flexMultiplier = 0.15, rotationMultiplier = 0.08, dimmingMultiplier = 0.6, cornerRadius = 0.04, ...props }, ref ) => { const containerRef = useRef(null) const [isLoaded, setIsLoaded] = useState(false) const [isReducedMotion, setIsReducedMotion] = useState(false) React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement) const scrollState = useRef({ targetY: 0, currentY: 0, velocity: 0, min: 0, max: 0, }) useGSAP( () => { if (!containerRef.current) return const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)") setIsReducedMotion(mediaQuery.matches) const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onWheel: (e) => { const state = scrollState.current const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX const isAtTop = state.targetY <= state.min && delta < 0 const isAtBottom = state.targetY >= state.max && delta > 0 if (!isAtTop && !isAtBottom) { state.targetY += delta * scrollSensitivity } }, onDrag: (e) => { const state = scrollState.current const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? -e.deltaY : -e.deltaX const isAtTop = state.targetY <= state.min && delta < 0 const isAtBottom = state.targetY >= state.max && delta > 0 if (!isAtTop && !isAtBottom) { state.targetY += delta * scrollSensitivity } }, }) return () => observer.kill() }, { scope: containerRef, dependencies: [scrollSensitivity] } ) return (

Interactive 3D Dimensional Deck.

) } ) DimensionalDeck.displayName = "DimensionalDeck" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { DimensionalDeck } from "@/registry/ui/dimensional-deck" export default function ExamplePage() { const images = [ "/image1.jpg", "/image2.jpg", "/image3.jpg" ] return (
) } ``` --- # Editorial Reveal Component Context **Description:** A premium, Awwwards-level text reveal component. Uses GSAP and ScrollTrigger to create a sophisticated, staggered redaction-block reveal that triggers exactly when the text enters the viewport. Includes full screen reader accessibility and vestibular disorder failsafes. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/editorial-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :---------------- | :------------------ | :---------------- | :---------------------------------------------------------------- | | `text` | `string` | _Required_ | The full string of text to reveal. Automatically splits by words. | | `as` | `React.ElementType` | `"p"` | The HTML tag to render as (e.g., `'h1'`, `'h2'`, `'p'`). | | `blockClassName` | `string` | `"bg-foreground"` | Tailwind class for the redaction block color. | | `triggerStart` | `string` | `"top 85%"` | Viewport threshold for when the animation should start. | | `duration` | `number` | `0.5` | The duration of the reveal for each individual block in seconds. | | `stagger` | `number` | `0.015` | The stagger delay between each word revealing in seconds. | | `ease` | `string` | `"power3.in"` | GSAP easing function for the scale animation. | | `reverseOnScroll` | `boolean` | `true` | Whether the blocks should close again when scrolling back up. | ## 3. Core Component Source **File Path:** `registry/ui/editorial-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface EditorialRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType blockClassName?: string triggerStart?: string duration?: number stagger?: number ease?: string reverseOnScroll?: boolean } export const EditorialReveal = React.forwardRef< HTMLElement, EditorialRevealProps >( ( { text, as = "p", className, blockClassName = "bg-foreground", triggerStart = "top 85%", duration = 0.5, stagger = 0.015, ease = "power3.in", reverseOnScroll = true, ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const blockNodes = gsap.utils.toArray( ".editorial-block", containerRef.current ) if (blockNodes.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { ScrollTrigger.batch(blockNodes, { start: triggerStart, onEnter: (batch) => gsap.to(batch, { scaleX: 0, duration, stagger, ease, overwrite: true, }), onLeaveBack: (batch) => { if (reverseOnScroll) { gsap.to(batch, { scaleX: 1, duration, stagger, ease, overwrite: true, }) } }, }) }) mm.add("(prefers-reduced-motion: reduce)", () => { ScrollTrigger.batch(blockNodes, { start: triggerStart, onEnter: (batch) => gsap.to(batch, { opacity: 0, duration, stagger, ease: "none", overwrite: true, }), onLeaveBack: (batch) => { if (reverseOnScroll) { gsap.to(batch, { opacity: 1, duration, stagger, ease: "none", overwrite: true, }) } }, }) }) return () => mm.revert() }, { scope: containerRef, dependencies: [triggerStart, duration, stagger, ease, reverseOnScroll], } ) const ssrBlockStyles: React.CSSProperties = { transform: "scaleX(1)", transformOrigin: "right center", willChange: "transform", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) EditorialReveal.displayName = "EditorialReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { EditorialReveal } from "@/registry/ui/editorial-reveal" export default function ExamplePage() { return (
) } ``` --- # Elastic Carousel Component Context **Description:** A high-performance WebGL scroll component for Satis UI. Mathematically interpolates between a flat 2D gallery and an immersive 3D cylindrical carousel depending entirely on the user's swipe velocity. Utilizes custom GLSL shaders for kinetic RGB splitting and aerodynamic bending. Implements GSAP Observer, strict container binding, and proper ARIA visual masking. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/elastic-carousel.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------------------- | :--------- | :--------- | :--------------------------------- | | `images` | `string[]` | _Required_ | Array of image URLs. | | `cardWidthRatio` | `number` | `0.35` | Screen width ratio. | | `cardAspectRatio` | `number` | `1.4` | Width/Height aspect ratio. | | `gapMultiplier` | `number` | `1.2` | 3D space gap multiplier. | | `radiusMultiplier` | `number` | `1.2` | Carousel radius distance. | | `scrollSensitivity` | `number` | `0.005` | Scroll input multiplier. | | `lerpFactor` | `number` | `0.08` | Smooth momentum decay. | | `flexMultiplier` | `number` | `0.5` | Velocity-to-cylinder bend factor. | | `parallaxIntensity` | `number` | `0.08` | Internal texture sliding distance. | | `chromaticAberrationIntensity` | `number` | `0.003` | Kinetic color separation. | | `dimmingMultiplier` | `number` | `0.4` | Shader-based fake depth occlusion. | | `cornerRadius` | `number` | `0.04` | GLSL-rendered SDF corner rounding. | ## 3. Core Component Source **File Path:** `registry/ui/elastic-carousel.tsx` ```tsx "use client" import React, { useRef, useState, useMemo, useEffect } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { useTexture } from "@react-three/drei" import * as THREE from "three" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface ElasticCarouselProps extends Omit< React.HTMLAttributes, "children" > { images: string[] cardWidthRatio?: number cardAspectRatio?: number gapMultiplier?: number radiusMultiplier?: number scrollSensitivity?: number lerpFactor?: number flexMultiplier?: number parallaxIntensity?: number chromaticAberrationIntensity?: number dimmingMultiplier?: number cornerRadius?: number } interface ScrollState { targetAngle: number currentAngle: number velocity: number bend: number min: number max: number } const ElasticVertexShader = ` precision mediump float; uniform float uVelocity; uniform float uFlexMultiplier; uniform float uBendFactor; varying vec2 vUv; void main() { vUv = uv; vec3 pos = position; float distFromCenter = abs(uv.x - 0.5) * 2.0; float flex = pow(distFromCenter, 2.0); pos.z += flex * abs(uVelocity) * 0.3 * uFlexMultiplier * uBendFactor; gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } ` const ElasticFragmentShader = ` precision mediump float; uniform sampler2D uTexture; uniform float uVelocity; uniform float uDepth; uniform float uAngle; uniform vec2 uResolution; uniform float uImageAspect; uniform float uParallaxIntensity; uniform float uChromaticAberrationIntensity; uniform float uCornerRadius; uniform float uDimmingMultiplier; varying vec2 vUv; void main() { float screenAspect = uResolution.x / uResolution.y; vec2 scale = vec2(1.0); if (screenAspect > uImageAspect) { scale.y = uImageAspect / screenAspect; } else { scale.x = screenAspect / uImageAspect; } vec2 parallaxUv = (vUv - 0.5) * (scale * 0.85) + 0.5; parallaxUv.x += clamp(uAngle, -1.0, 1.0) * uParallaxIntensity; float split = abs(uVelocity) * uChromaticAberrationIntensity; float r = texture2D(uTexture, parallaxUv + vec2(split, 0.0)).r; float g = texture2D(uTexture, parallaxUv).g; float b = texture2D(uTexture, parallaxUv - vec2(split, 0.0)).b; vec4 texColor = vec4(r, g, b, 1.0); vec2 pos = vUv - 0.5; vec2 pixelPos = pos * uResolution; vec2 pixelSize = vec2(0.5) * uResolution; float pixelRadius = uCornerRadius * min(uResolution.x, uResolution.y); float dist = length(max(abs(pixelPos) - pixelSize + pixelRadius, 0.0)) - pixelRadius; float cornerAlpha = 1.0 - smoothstep(0.0, 1.5, dist); float depthFactor = smoothstep(0.0, 1.0, (uDepth + 1.0) / 2.0); vec3 darkenedColor = mix(texColor.rgb * (1.0 - uDimmingMultiplier), texColor.rgb, depthFactor); float alpha = mix(0.0, 1.0, smoothstep(0.2, 0.8, depthFactor)); gl_FragColor = vec4(darkenedColor, cornerAlpha * alpha); } ` function ElasticScene({ images, scrollState, onReady, cardWidthRatio, cardAspectRatio, lerpFactor, radiusMultiplier, gapMultiplier, flexMultiplier, parallaxIntensity, chromaticAberrationIntensity, dimmingMultiplier, cornerRadius, isReducedMotion, }: ElasticCarouselProps & { scrollState: React.MutableRefObject onReady: () => void isReducedMotion: boolean }) { const textures = useTexture(images) const { viewport } = useThree() const groupRef = useRef(null) const isMobile = viewport.width < 5 let itemWidth = isMobile ? viewport.width * 0.6 : viewport.width * cardWidthRatio! let itemHeight = itemWidth * cardAspectRatio! const maxHeight = viewport.height * (isMobile ? 0.6 : 0.5) if (itemHeight > maxHeight) { itemHeight = maxHeight itemWidth = itemHeight / cardAspectRatio! } const radius = viewport.width * radiusMultiplier! const angleSpacing = (itemWidth / radius) * gapMultiplier! const geometry = useMemo( () => new THREE.PlaneGeometry(itemWidth, itemHeight, 32, 32), [itemWidth, itemHeight] ) const materials = useMemo(() => { return textures.map((texture) => { const img = texture.image as { width?: number; height?: number } | null | undefined const imageAspect = img?.width && img?.height ? img.width / img.height : 1 return new THREE.ShaderMaterial({ vertexShader: ElasticVertexShader, fragmentShader: ElasticFragmentShader, uniforms: { uTexture: { value: texture }, uVelocity: { value: 0 }, uDepth: { value: 1.0 }, uAngle: { value: 0.0 }, uBendFactor: { value: 0.0 }, uResolution: { value: new THREE.Vector2(itemWidth, itemHeight) }, uImageAspect: { value: imageAspect }, uFlexMultiplier: { value: isReducedMotion ? 0 : flexMultiplier }, uParallaxIntensity: { value: isReducedMotion ? 0 : parallaxIntensity }, uChromaticAberrationIntensity: { value: isReducedMotion ? 0 : chromaticAberrationIntensity }, uCornerRadius: { value: cornerRadius }, uDimmingMultiplier: { value: dimmingMultiplier }, }, transparent: true, depthWrite: false, }) }) }, [ textures, itemWidth, itemHeight, flexMultiplier, parallaxIntensity, chromaticAberrationIntensity, cornerRadius, dimmingMultiplier, isReducedMotion, ]) useEffect(() => { return () => { geometry.dispose() materials.forEach((m) => m.dispose()) } }, [geometry, materials]) useEffect(() => { scrollState.current.min = 0 scrollState.current.max = (images.length - 1) * angleSpacing requestAnimationFrame(() => onReady()) }, [images.length, angleSpacing, scrollState, onReady]) useFrame((_, delta) => { const state = scrollState.current const dt = Math.min(delta, 0.1) state.targetAngle = THREE.MathUtils.clamp(state.targetAngle, state.min, state.max) const prevAngle = state.currentAngle state.currentAngle = THREE.MathUtils.damp( state.currentAngle, state.targetAngle, lerpFactor! * 100, dt ) const angleDelta = state.currentAngle - prevAngle const trueVelocity = angleDelta / dt state.velocity = THREE.MathUtils.damp(state.velocity, trueVelocity * 0.3, 5, dt) const targetBend = Math.min(Math.abs(trueVelocity) * flexMultiplier!, 1.0) state.bend = THREE.MathUtils.damp(state.bend, targetBend, 3.5, dt) if (groupRef.current) { groupRef.current.children.forEach((mesh: THREE.Mesh | any, reversedIndex) => { const originalIndex = textures.length - 1 - reversedIndex const material = materials[originalIndex] if (!material) return const angle = originalIndex * angleSpacing - state.currentAngle const flatX = angle * radius const flatZ = 0 const flatRotY = 0 const curveX = Math.sin(angle) * radius const curveZ = Math.cos(angle) * radius - radius const curveRotY = angle mesh.position.x = THREE.MathUtils.lerp(flatX, curveX, state.bend) mesh.position.z = THREE.MathUtils.lerp(flatZ, curveZ, state.bend) mesh.rotation.y = THREE.MathUtils.lerp(flatRotY, curveRotY, state.bend) mesh.renderOrder = 1000 + Math.cos(angle) * 100 material.uniforms.uVelocity.value = state.velocity material.uniforms.uAngle.value = angle material.uniforms.uBendFactor.value = state.bend material.uniforms.uDepth.value = THREE.MathUtils.lerp(1.0, Math.cos(angle), state.bend) }) } }) return ( {[...textures].reverse().map((_, reversedIndex) => { const originalIndex = textures.length - 1 - reversedIndex return ( ) })} ) } export const ElasticCarousel = React.forwardRef< HTMLDivElement, ElasticCarouselProps >( ( { images, className, cardWidthRatio = 0.35, cardAspectRatio = 1.4, gapMultiplier = 1.2, radiusMultiplier = 1.2, scrollSensitivity = 0.005, lerpFactor = 0.08, flexMultiplier = 0.5, parallaxIntensity = 0.08, chromaticAberrationIntensity = 0.003, dimmingMultiplier = 0.4, cornerRadius = 0.04, ...props }, ref ) => { const containerRef = useRef(null) const [isLoaded, setIsLoaded] = useState(false) const [isReducedMotion, setIsReducedMotion] = useState(false) React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement) const scrollState = useRef({ targetAngle: 0, currentAngle: 0, velocity: 0, bend: 0, min: 0, max: 0, }) useGSAP( () => { if (!containerRef.current) return const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)") setIsReducedMotion(mediaQuery.matches) const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onWheel: (e) => { const delta = (e.deltaX || 0) + (e.deltaY || 0) scrollState.current.targetAngle += delta * scrollSensitivity }, onDrag: (e) => { scrollState.current.targetAngle -= e.deltaX * scrollSensitivity }, }) return () => observer.kill() }, { scope: containerRef, dependencies: [scrollSensitivity] } ) return (

Interactive 3D Elastic Carousel. Scroll or swipe to navigate.

) } ) ElasticCarousel.displayName = "ElasticCarousel" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { ElasticCarousel } from "@/registry/ui/elastic-carousel" export default function ExamplePage() { const images = [ "/image1.jpg", "/image2.jpg", "/image3.jpg" ] return (
) } ``` --- # Elastic Pop Reveal Component Context **Description:** A tactile, physics-based text reveal component for Satis UI. Splinters text into words or characters and scales them in with a highly customizable GSAP elastic spring effect, triggered on scroll. Features robust screen reader support and vestibular failsafes. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/elastic-pop-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :---------------------- | :---------------------------------------------------- | | `text` | `string` | _Required_ | The string of text to reveal. | | `as` | `React.ElementType` | `"div"` | HTML tag to render. | | `splitBy` | `"word" \| "char"` | `"word"` | Split mode. | | `startScale` | `number` | `0.5` | Starting scale of the elements. | | `startOpacity` | `number` | `0` | Starting opacity. | | `delay` | `number` | `0` | Initial animation delay. | | `ease` | `string` | `"elastic.out(1, 0.4)"` | Spring configuration (amplitude, frequency). | | `duration` | `number` | `1.5` | Animation duration. | | `stagger` | `number` | `0.05` | Stagger timing between items. | | `viewportOnce` | `boolean` | `true` | Reverse animation when scrolled out of view if false. | | `triggerStart` | `string` | `"top 90%"` | ScrollTrigger start position. | ## 3. Core Component Source **File Path:** `registry/ui/elastic-pop-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface ElasticPopRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" startScale?: number startOpacity?: number delay?: number ease?: string duration?: number stagger?: number viewportOnce?: boolean triggerStart?: string } export const ElasticPopReveal = React.forwardRef< HTMLElement, ElasticPopRevealProps >( ( { text, as = "div", className, splitBy = "word", startScale = 0.5, startOpacity = 0, delay = 0, duration = 1.5, stagger = 0.05, ease = "elastic.out(1, 0.4)", viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".pop-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { scale: startScale, opacity: startOpacity, }, { scale: 1, opacity: 1, duration, stagger, delay, ease, force3D: true, scrollTrigger: { trigger: containerRef.current, start: triggerStart, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: startOpacity }, { opacity: 1, duration: 0.5, stagger, delay, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ startScale, startOpacity, duration, stagger, delay, ease, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { willChange: "transform, opacity", opacity: startOpacity, transform: `scale(${startScale})`, transformOrigin: "center center", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) ElasticPopReveal.displayName = "ElasticPopReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { ElasticPopReveal } from "@/registry/ui/elastic-pop-reveal" export default function ExamplePage() { return (
) } ``` --- # Elastic Typewriter Component Context **Description:** A highly kinetic typewriter effect for Satis UI. A cursor glides across the text, while individual characters stretch, squeeze, skew, and elastic-snap into place to simulate mechanical physical tension. Features robust window resizing recalculations and screen reader support. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/elastic-typewriter.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :---------------- | :------------------ | :------------- | :------------------------------------------ | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `cursorClassName` | `string` | `"bg-primary"` | Classes for the cursor. | | `baseSpeed` | `number` | `0.04` | Gliding speed base (seconds). | | `variance` | `number` | `0.02` | Random speed variance for realistic typing. | | `delay` | `number` | `0` | Intro delay. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 90%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/elastic-typewriter.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface ElasticTypewriterProps extends React.HTMLAttributes { text: string as?: React.ElementType cursorClassName?: string baseSpeed?: number variance?: number delay?: number viewportOnce?: boolean triggerStart?: string } export const ElasticTypewriter = React.forwardRef< HTMLElement, ElasticTypewriterProps >( ( { text, as = "h1", className, cursorClassName = "bg-primary", baseSpeed = 0.04, variance = 0.02, delay = 0, viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) const cursorRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current || !cursorRef.current) return const charContainers = gsap.utils.toArray( ".elastic-char-container", containerRef.current ) if (charContainers.length === 0) return let tl: gsap.core.Timeline const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { const cursorBlink = gsap.fromTo( cursorRef.current, { opacity: 1 }, { opacity: 0, duration: 0.6, ease: "power2.inOut", repeat: -1, yoyo: true, } ) cursorBlink.pause() tl = gsap.timeline({ delay: delay, scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, onStart: () => { gsap.set(cursorRef.current, { x: charContainers[0].offsetLeft, y: charContainers[0].offsetTop, opacity: 1, display: "inline-block", }) }, onComplete: () => { cursorBlink.play() }, }) let timePos = 0 charContainers.forEach((container, i) => { const charText = container.getAttribute("data-char") || "" const isLast = i === charContainers.length - 1 let nextX, nextY if (isLast) { nextX = container.offsetLeft + container.offsetWidth nextY = container.offsetTop } else { nextX = charContainers[i + 1].offsetLeft nextY = charContainers[i + 1].offsetTop } const isLineBreak = nextY > container.offsetTop + 5 let duration = baseSpeed + Math.random() * variance if (isLineBreak) duration = 0.15 else if (charText === " ") duration = baseSpeed * 1.5 tl.to( cursorRef.current, { x: nextX, y: nextY, duration: duration, ease: isLineBreak ? "power2.inOut" : "none", }, timePos ) const visibleChar = container.querySelector(".elastic-visible") if (visibleChar) { tl.fromTo( visibleChar, { opacity: 0, y: 20, scaleY: 1.5, scaleX: 0.7, skewX: -20, }, { opacity: 1, y: 0, scaleY: 1, scaleX: 1, skewX: 0, duration: 1.2, ease: "elastic.out(1, 0.3)", force3D: true, }, timePos ) } timePos += duration if (/[.,!?]/.test(charText)) { timePos += 0.25 } }) const handleResize = () => { if (!containerRef.current || !cursorRef.current) return if (tl.progress() > 0 && tl.progress() < 1) { tl.progress(1) } const last = charContainers[charContainers.length - 1] if (last) { gsap.set(cursorRef.current, { x: last.offsetLeft + last.offsetWidth, y: last.offsetTop, }) } } window.addEventListener("resize", handleResize) return () => window.removeEventListener("resize", handleResize) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.set(cursorRef.current, { display: "none" }) const visibleChars = gsap.utils.toArray(".elastic-visible", containerRef.current) gsap.fromTo( visibleChars, { opacity: 0 }, { opacity: 1, duration: 0.5, stagger: 0.02, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [baseSpeed, variance, delay, triggerStart, viewportOnce], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: "translateY(20px) scaleY(1.5) scaleX(0.7) skewX(-20deg)", transformOrigin: "bottom center", willChange: "transform, opacity", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) ElasticTypewriter.displayName = "ElasticTypewriter" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { ElasticTypewriter } from "@/registry/ui/elastic-typewriter" export default function ExamplePage() { return (
) } ``` --- # Ember Burn Component Context **Description:** An interactive image transition component for Satis UI. Simulates a burning ember hole using complex SVG displacement maps and color matrices. The burn originates exactly from the mouse entry coordinates. Uses GSAP `contextSafe` for flawless garbage collection and ARIA attributes to prevent screen-reader noise from the massive SVG block. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/ember-burn.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :---------------- | :---------- | :--------------- | :---------------------------- | | `imageUrl` | `string` | _Required_ | The image URL to burn away. | | `children` | `ReactNode` | _Required_ | Content revealed underneath. | | `duration` | `number` | `2.5` | Animation duration. | | `maxDisplacement` | `number` | `400` | Maximum turbulence intensity. | | `ease` | `string` | `"power2.inOut"` | Easing function. | ## 3. Core Component Source **File Path:** `registry/ui/ember-burn.tsx` ```tsx "use client" import React, { useRef, useMemo, useId } from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { cn } from "@/lib/utils" export interface EmberBurnProps extends React.HTMLAttributes { imageUrl: string children: React.ReactNode duration?: number maxDisplacement?: number ease?: string className?: string } export function EmberBurn({ imageUrl, children, duration = 2.5, maxDisplacement = 400, ease = "power2.inOut", className, ...props }: EmberBurnProps) { const containerRef = useRef(null) const tl = useRef(null) const proxyRef = useRef({ r: 0, dispScale: 0 }) const rawId = useId() const maskFilterId = useMemo(() => `ember-mask-filter-${rawId.replace(/:/g, "")}`, [rawId]) const maskId = useMemo(() => `ember-mask-${rawId.replace(/:/g, "")}`, [rawId]) const blurId = useMemo(() => `ember-blur-${rawId.replace(/:/g, "")}`, [rawId]) const glowId = useMemo(() => `ember-glow-${rawId.replace(/:/g, "")}`, [rawId]) const { contextSafe } = useGSAP({ scope: containerRef }) const handleMouseEnter = contextSafe((e: React.MouseEvent) => { if (!containerRef.current) return if (tl.current && tl.current.progress() > 0 && tl.current.progress() < 1) { tl.current.play() return } const rect = containerRef.current.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top const width = rect.width const height = rect.height const maxRadius = Math.hypot(width, height) + 150 const hole = containerRef.current.querySelector(".ember-hole") const disp = containerRef.current.querySelector(".ember-displacement") if (!hole || !disp) return gsap.set(hole, { attr: { cx: x, cy: y } }) if (tl.current) tl.current.kill() proxyRef.current.r = 0 proxyRef.current.dispScale = 0 tl.current = gsap.timeline() tl.current.to(proxyRef.current, { r: maxRadius, dispScale: maxDisplacement, duration: duration, ease: ease, onUpdate: () => { hole.setAttribute("r", proxyRef.current.r.toString()) disp.setAttribute("scale", proxyRef.current.dispScale.toString()) }, }) }) const handleMouseLeave = contextSafe(() => { if (tl.current) { tl.current.reverse() } }) return (
{children}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { EmberBurn } from "@/registry/ui/ember-burn" export default function ExamplePage() { return (

Tada!

) } ``` --- # Ferrofluid Drag Component Context **Description:** An interactive image transition component for Satis UI. Applies a mathematical SVG Gooey filter to a grid of beads. On hover, the beads calculate their proximity to the cursor and violently tear outward like magnetic fluid, revealing the content underneath. Utilizes GSAP `contextSafe` for strict memory management and ARIA presentation standards for screen readers. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/ferrofluid-drag.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :-------------- | :---------- | :------------- | :------------------------------- | | `imageUrl` | `string` | _Required_ | The top image URL. | | `children` | `ReactNode` | _Required_ | Reveal content. | | `columns` | `number` | `12` | Grid column count. | | `rows` | `number` | `12` | Grid row count. | | `duration` | `number` | `1.2` | Tear animation duration. | | `staggerAmount` | `number` | `0.4` | Wave completion time allocation. | | `ease` | `string` | `"power2.out"` | Easing curve. | ## 3. Core Component Source **File Path:** `registry/ui/ferrofluid-drag.tsx` ```tsx "use client" import React, { useRef, useMemo, useId } from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { cn } from "@/lib/utils" export interface FerrofluidDragProps extends React.HTMLAttributes { imageUrl: string children: React.ReactNode columns?: number rows?: number duration?: number staggerAmount?: number ease?: string className?: string } export function FerrofluidDrag({ imageUrl, children, columns = 12, rows = 12, duration = 1.2, staggerAmount = 0.4, ease = "power2.out", className, ...props }: FerrofluidDragProps) { const containerRef = useRef(null) const tl = useRef(null) const rawId = useId() const filterId = useMemo(() => `tear-filter-${rawId.replace(/:/g, "")}`, [rawId]) const maskId = useMemo(() => `tear-mask-${rawId.replace(/:/g, "")}`, [rawId]) const cells = useMemo(() => { return Array.from({ length: columns * rows }).map((_, i) => { const col = i % columns const row = Math.floor(i / columns) return { id: i, cx: `${(col / (columns - 1)) * 100}%`, cy: `${(row / (rows - 1)) * 100}%`, rawCx: (col / (columns - 1)) * 100, rawCy: (row / (rows - 1)) * 100, } }) }, [columns, rows]) const { contextSafe } = useGSAP({ scope: containerRef }) const handleMouseEnter = contextSafe((e: React.MouseEvent) => { if (!containerRef.current) return if (tl.current && tl.current.progress() > 0 && tl.current.progress() < 1) { tl.current.play() return } const rect = containerRef.current.getBoundingClientRect() const cursorX = e.clientX - rect.left const cursorY = e.clientY - rect.top const col = Math.max( 0, Math.min(columns - 1, Math.round((cursorX / rect.width) * (columns - 1))) ) const row = Math.max( 0, Math.min(rows - 1, Math.round((cursorY / rect.height) * (rows - 1))) ) const startIndex = row * columns + col const circles = gsap.utils.toArray(".tear-bead", containerRef.current) if (tl.current) tl.current.kill() tl.current = gsap.timeline() tl.current.to(circles, { x: (i, target) => { const cx = (parseFloat(target.dataset.cx) / 100) * rect.width const cy = (parseFloat(target.dataset.cy) / 100) * rect.height const dx = cx - cursorX const dy = cy - cursorY const angle = Math.atan2(dy, dx) const dist = Math.hypot(dx, dy) || 1 const pushForce = Math.max(100, 350 - dist) + gsap.utils.random(0, 100) return Math.cos(angle) * pushForce }, y: (i, target) => { const cx = (parseFloat(target.dataset.cx) / 100) * rect.width const cy = (parseFloat(target.dataset.cy) / 100) * rect.height const dx = cx - cursorX const dy = cy - cursorY const angle = Math.atan2(dy, dx) const dist = Math.hypot(dx, dy) || 1 const pushForce = Math.max(100, 350 - dist) + gsap.utils.random(0, 100) return Math.sin(angle) * pushForce }, scale: 0, duration: duration, ease: ease, force3D: true, stagger: { amount: staggerAmount, grid: [rows, columns], from: startIndex, }, }) }) const handleMouseLeave = contextSafe(() => { if (tl.current) { tl.current.reverse() } }) return (
{children}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { FerrofluidDrag } from "@/registry/ui/ferrofluid-drag" export default function ExamplePage() { return (

Tada!

) } ``` --- # Flex Carousel Component Context **Description:** A high-performance WebGL horizontal carousel for Satis UI. Integrates aerodynamic bending, kinetic RGB splitting, dynamic shadowing, and smooth SDF corner radiuses calculated entirely on the GPU. Unifies trackpad swipe and wheel data natively through GSAP Observer. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/flex-carousel.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------------------- | :--------- | :--------- | :--------------------------------- | | `images` | `string[]` | _Required_ | Array of image URLs. | | `cardWidthRatio` | `number` | `0.35` | Screen width ratio. | | `cardAspectRatio` | `number` | `1.4` | Width/Height ratio. | | `gapMultiplier` | `number` | `0.36` | Distance between cards. | | `scrollSensitivity` | `number` | `0.04` | Input multiplier. | | `lerpFactor` | `number` | `0.08` | Smooth momentum decay. | | `flexMultiplier` | `number` | `0.25` | Paper aerodynamic bend. | | `rotationMultiplier` | `number` | `0.02` | Rotation facing the center. | | `parallaxIntensity` | `number` | `0.05` | Internal sliding texture parallax. | | `chromaticAberrationIntensity` | `number` | `0.005` | Kinetic RGB separation. | | `dimmingMultiplier` | `number` | `0.015` | Shadow intensity on flex edges. | | `cornerRadius` | `number` | `0.04` | SDF corner rounding. | ## 3. Core Component Source **File Path:** `registry/ui/flex-carousel.tsx` ```tsx "use client" import React, { useRef, useState, useMemo, useEffect } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { useTexture } from "@react-three/drei" import * as THREE from "three" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface FlexCarouselProps extends Omit, "children"> { images: string[] className?: string cardWidthRatio?: number cardAspectRatio?: number gapMultiplier?: number scrollSensitivity?: number lerpFactor?: number flexMultiplier?: number rotationMultiplier?: number parallaxIntensity?: number chromaticAberrationIntensity?: number dimmingMultiplier?: number cornerRadius?: number } interface ScrollState { targetX: number currentX: number velocity: number min: number max: number } const FlexVertexShader = ` precision mediump float; uniform float uVelocity; uniform float uFlexMultiplier; varying vec2 vUv; void main() { vUv = uv; vec3 pos = position; float direction = uVelocity >= 0.0 ? 1.0 : -1.0; float speed = abs(uVelocity); float trailing = direction > 0.0 ? (1.0 - uv.x) : uv.x; float flex = pow(trailing, 2.0); pos.z -= flex * speed * uFlexMultiplier; pos.x += flex * uVelocity * 0.3; gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } ` const FlexFragmentShader = ` precision mediump float; uniform sampler2D uTexture; uniform float uVelocity; uniform float uAngle; uniform vec2 uResolution; uniform float uImageAspect; uniform float uParallaxIntensity; uniform float uChromaticAberrationIntensity; uniform float uCornerRadius; uniform float uDimmingMultiplier; varying vec2 vUv; void main() { float screenAspect = uResolution.x / uResolution.y; vec2 scale = vec2(1.0); if (screenAspect > uImageAspect) { scale.y = uImageAspect / screenAspect; } else { scale.x = screenAspect / uImageAspect; } vec2 parallaxUv = (vUv - 0.5) * (scale * 0.85) + 0.5; parallaxUv.x += clamp(uAngle, -1.0, 1.0) * uParallaxIntensity; float speed = abs(uVelocity); float split = speed * uChromaticAberrationIntensity; float r = texture2D(uTexture, parallaxUv + vec2(split, 0.0)).r; float g = texture2D(uTexture, parallaxUv).g; float b = texture2D(uTexture, parallaxUv - vec2(split, 0.0)).b; vec4 texColor = vec4(r, g, b, 1.0); float edgeDist = pow(abs(vUv.x - 0.5) * 2.0, 3.0); vec3 leakColor = vec3(1.0, 0.85, 0.7); float leakIntensity = edgeDist * speed * 0.06; vec3 finalColor = texColor.rgb + (leakColor * leakIntensity); float shadow = edgeDist * speed * uDimmingMultiplier; finalColor -= shadow; vec2 pos = vUv - 0.5; vec2 pixelPos = pos * uResolution; vec2 pixelSize = vec2(0.5) * uResolution; float pixelRadius = uCornerRadius * min(uResolution.x, uResolution.y); float dist = length(max(abs(pixelPos) - pixelSize + pixelRadius, 0.0)) - pixelRadius; float cornerAlpha = 1.0 - smoothstep(0.0, 1.5, dist); gl_FragColor = vec4(finalColor, cornerAlpha); } ` function FlexScene({ images, scrollState, onReady, cardWidthRatio, cardAspectRatio, lerpFactor, gapMultiplier, flexMultiplier, rotationMultiplier, parallaxIntensity, chromaticAberrationIntensity, dimmingMultiplier, cornerRadius, }: FlexCarouselProps & { scrollState: React.MutableRefObject onReady: () => void }) { const textures = useTexture(images) const { viewport } = useThree() const groupRef = useRef(null) const isMobile = viewport.width < 5 let itemWidth = isMobile ? viewport.width * 0.6 : viewport.width * cardWidthRatio! let itemHeight = itemWidth * cardAspectRatio! const maxHeight = viewport.height * (isMobile ? 0.6 : 0.5) if (itemHeight > maxHeight) { itemHeight = maxHeight itemWidth = itemHeight / cardAspectRatio! } const spacing = viewport.width * gapMultiplier! const geometry = useMemo( () => new THREE.PlaneGeometry(itemWidth, itemHeight, 64, 64), [itemWidth, itemHeight] ) const materials = useMemo(() => { return textures.map((texture) => { const img = texture.image as { width?: number; height?: number } | null | undefined const imageAspect = img?.width && img?.height ? img.width / img.height : 1 return new THREE.ShaderMaterial({ vertexShader: FlexVertexShader, fragmentShader: FlexFragmentShader, uniforms: { uTexture: { value: texture }, uVelocity: { value: 0 }, uAngle: { value: 0 }, uResolution: { value: new THREE.Vector2(itemWidth, itemHeight) }, uImageAspect: { value: imageAspect }, uFlexMultiplier: { value: flexMultiplier }, uParallaxIntensity: { value: parallaxIntensity }, uChromaticAberrationIntensity: { value: chromaticAberrationIntensity }, uDimmingMultiplier: { value: dimmingMultiplier }, uCornerRadius: { value: cornerRadius }, }, transparent: true, depthWrite: false, }) }) }, [ textures, itemWidth, itemHeight, flexMultiplier, parallaxIntensity, chromaticAberrationIntensity, dimmingMultiplier, cornerRadius, ]) useEffect(() => { return () => { geometry.dispose() materials.forEach((m) => m.dispose()) } }, [geometry, materials]) useEffect(() => { const centerOffset = isMobile ? 1 : 2 scrollState.current.min = -spacing * centerOffset scrollState.current.max = (images.length - 1) * spacing + spacing * centerOffset requestAnimationFrame(() => onReady()) }, [images.length, spacing, scrollState, onReady, isMobile]) useFrame((_, delta) => { const state = scrollState.current const dt = Math.min(delta, 0.1) state.targetX = THREE.MathUtils.clamp(state.targetX, state.min, state.max) const prevX = state.currentX state.currentX = THREE.MathUtils.damp(state.currentX, state.targetX, lerpFactor! * 100, dt) const rawVelocity = (state.currentX - prevX) / dt state.velocity = THREE.MathUtils.damp(state.velocity, rawVelocity * 0.15, 5, dt) if (groupRef.current) { groupRef.current.children.forEach((mesh: any, i) => { const material = materials[i] if (!material) return const relativeX = i * spacing - state.currentX mesh.position.x = relativeX mesh.rotation.y = -rotationMultiplier! mesh.renderOrder = 1000 - Math.abs(relativeX) material.uniforms.uVelocity.value = state.velocity material.uniforms.uAngle.value = relativeX * 0.1 }) } }) return ( {textures.map((_, i) => ( ))} ) } export const FlexCarousel = React.forwardRef< HTMLDivElement, FlexCarouselProps >( ( { images, className, cardWidthRatio = 0.35, cardAspectRatio = 1.4, gapMultiplier = 0.36, scrollSensitivity = 0.04, lerpFactor = 0.08, flexMultiplier = 0.25, rotationMultiplier = 0.02, parallaxIntensity = 0.05, chromaticAberrationIntensity = 0.005, dimmingMultiplier = 0.015, cornerRadius = 0.04, ...props }, ref ) => { const containerRef = useRef(null) const [isLoaded, setIsLoaded] = useState(false) React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement) const scrollState = useRef({ targetX: 0, currentX: 0, velocity: 0, min: 0, max: 0, }) useGSAP( () => { if (!containerRef.current) return const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onWheel: (e) => { const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY scrollState.current.targetX += delta * scrollSensitivity }, onDrag: (e) => { const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? -e.deltaX : -e.deltaY scrollState.current.targetX += delta * scrollSensitivity }, }) return () => observer.kill() }, { scope: containerRef, dependencies: [scrollSensitivity] } ) return (

Interactive 3D Image Carousel. Scroll to navigate.

{images.map((img, i) => ( {`Slide ))}
) } ) FlexCarousel.displayName = "FlexCarousel" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { FlexCarousel } from "@/registry/ui/flex-carousel" export default function ExamplePage() { const images = [ "/image1.jpg", "/image2.jpg", "/image3.jpg" ] return (
) } ``` --- # Flip 3D Reveal Component Context **Description:** A premium, mechanical text reveal component for Satis UI. Characters or words rotate into view along the Y-axis, creating a Rolodex or split-flap display effect with a microscopic physics bounce. Built with GSAP ScrollTrigger and features strict accessibility compliance. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/flip-3d-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :---------- | :---------------------------------- | | `text` | `string` | _Required_ | The string of text to reveal. | | `as` | `React.ElementType` | `"h1"` | HTML tag to render. | | `splitBy` | `"word" \| "char"` | `"char"` | Split text granularity. | | `startAngle` | `number` | `90` | Starting Y-axis rotation (degrees). | | `startX` | `string` | `"-0.2em"` | Initial horizontal offset. | | `duration` | `number` | `0.8` | Animation duration per piece. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.03` | Stagger timing. | | `viewportOnce` | `boolean` | `true` | Toggle repeating on scroll. | | `triggerStart` | `string` | `"top 90%"` | Scroll trigger start position. | ## 3. Core Component Source **File Path:** `registry/ui/flip-3d-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface Flip3DRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" startAngle?: number startX?: string duration?: number delay?: number stagger?: number viewportOnce?: boolean triggerStart?: string } export const Flip3DReveal = React.forwardRef( ( { text, as = "h1", className, splitBy = "char", startAngle = 90, startX = "-0.2em", duration = 0.8, delay = 0, stagger = 0.03, viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".flip-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { rotateY: startAngle, x: startX, opacity: 0, }, { rotateY: 0, x: 0, opacity: 1, duration: duration, delay: delay, stagger: stagger, ease: "back.out(1.2)", force3D: true, scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.6, delay: delay, stagger: stagger, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ startAngle, startX, duration, delay, stagger, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: `rotateY(${startAngle}deg) translateX(${startX})`, transformStyle: "preserve-3d", willChange: "transform, opacity", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) Flip3DReveal.displayName = "Flip3DReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { Flip3DReveal } from "@/registry/ui/flip-3d-reveal" export default function ExamplePage() { return (
) } ``` --- # Flip Vertical Reveal Component Context **Description:** A 3D mechanical text reveal component for Satis UI. Simulates a split-flap display or falling dominoes by hinging characters or words down from a 90-degree 3D perspective. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/flip-vertical-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :---------- | :---------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML tag to render. | | `splitBy` | `"word" \| "char"` | `"char"` | Split mode. | | `startAngle` | `number` | `-90` | Starting X-axis rotation. | | `startY` | `string` | `"0.4em"` | Starting Y-axis translation. | | `duration` | `number` | `0.8` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.03` | Stagger timing between items. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 90%"` | ScrollTrigger start position. | ## 3. Core Component Source **File Path:** `registry/ui/flip-vertical-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface FlipVerticalRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" startAngle?: number startY?: string duration?: number delay?: number stagger?: number viewportOnce?: boolean triggerStart?: string } export const FlipVerticalReveal = React.forwardRef< HTMLElement, FlipVerticalRevealProps >( ( { text, as = "h1", className, splitBy = "char", startAngle = -90, startY = "0.4em", duration = 0.8, delay = 0, stagger = 0.03, viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".flip-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { rotateX: startAngle, y: startY, opacity: 0, }, { rotateX: 0, y: 0, opacity: 1, duration, delay, stagger, ease: "back.out(1.4)", force3D: true, scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ startAngle, startY, duration, delay, stagger, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: `translateY(${startY}) rotateX(${startAngle}deg)`, transformOrigin: "bottom center", transformStyle: "preserve-3d", willChange: "transform, opacity", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) FlipVerticalReveal.displayName = "FlipVerticalReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { FlipVerticalReveal } from "@/registry/ui/flip-vertical-reveal" export default function ExamplePage() { return (
) } ``` --- # Fluid Disintegration Component Context **Description:** An interactive liquid image transition component for Satis UI. Fragments an image into an SVG grid mapped with a gooey color matrix. On hover, the droplets calculate their proximity to the cursor and melt outward, revealing the content underneath. Utilizes GSAP `contextSafe` for strict memory management and ARIA presentation standards for screen readers. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/fluid-disintegration.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------- | :---------- | :------------- | :------------------------------- | | `imageUrl` | `string` | _Required_ | The image URL. | | `children` | `ReactNode` | _Required_ | Reveal content. | | `rows` | `number` | `12` | Grid row count. | | `columns` | `number` | `12` | Grid column count. | | `duration` | `number` | `0.8` | Droplet animation duration. | | `staggerAmount` | `number` | `0.6` | Wave completion time allocation. | | `rotationRange` | `number` | `45` | Random rotation variance. | | `translationRange` | `number` | `25` | Random translation variance. | | `ease` | `string` | `"sine.inOut"` | Easing curve. | ## 3. Core Component Source **File Path:** `registry/ui/fluid-disintegration.tsx` ```tsx "use client" import React, { useRef, useMemo, useId } from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { cn } from "@/lib/utils" export interface FluidDisintegrationProps extends React.HTMLAttributes { imageUrl: string children: React.ReactNode rows?: number columns?: number duration?: number staggerAmount?: number rotationRange?: number translationRange?: number ease?: string className?: string } export function FluidDisintegration({ imageUrl, children, rows = 12, columns = 12, duration = 0.8, staggerAmount = 0.6, rotationRange = 45, translationRange = 25, ease = "sine.inOut", className, ...props }: FluidDisintegrationProps) { const containerRef = useRef(null) const tl = useRef(null) const rawId = useId() const filterId = useMemo(() => `fluid-filter-${rawId.replace(/:/g, "")}`, [rawId]) const patternId = useMemo(() => `fluid-pattern-${rawId.replace(/:/g, "")}`, [rawId]) const gridCells = useMemo(() => { return Array.from({ length: rows * columns }).map((_, i) => { const r = Math.floor(i / columns) const c = i % columns return { id: i, x: (c / columns) * 100, y: (r / rows) * 100, width: 100 / columns, height: 100 / rows, } }) }, [rows, columns]) const { contextSafe } = useGSAP({ scope: containerRef }) const handleMouseEnter = contextSafe((e: React.MouseEvent) => { if (!containerRef.current) return if (tl.current && tl.current.progress() > 0 && tl.current.progress() < 1) { tl.current.play() return } const rect = containerRef.current.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top const cellWidthPx = rect.width / columns const cellHeightPx = rect.height / rows const col = Math.max(0, Math.min(columns - 1, Math.floor(x / cellWidthPx))) const row = Math.max(0, Math.min(rows - 1, Math.floor(y / cellHeightPx))) const startIndex = row * columns + col const pixels = gsap.utils.toArray(".fluid-drop", containerRef.current) if (tl.current) tl.current.kill() tl.current = gsap.timeline() tl.current.to(pixels, { transformOrigin: "50% 50%", scale: 0, x: () => gsap.utils.random(-translationRange, translationRange), y: () => gsap.utils.random(-translationRange, translationRange), rotation: () => gsap.utils.random(-rotationRange, rotationRange), duration: duration, stagger: { amount: staggerAmount, grid: [rows, columns], from: startIndex, }, ease: ease, force3D: true, }) }) const handleMouseLeave = contextSafe(() => { if (tl.current) { tl.current.reverse() } }) return (
{children}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { FluidDisintegration } from "@/registry/ui/fluid-disintegration" export default function ExamplePage() { return (

Tada!

) } ``` --- # Fluid Ink Reveal Component Context **Description:** A liquid text reveal component for Satis UI. Uses a dynamically animated SVG color matrix to warp blurry elements into sharp liquid droplets that merge and snap into crisp typography. Flawlessly interpolates back to native browser anti-aliasing to prevent snapping artifacts. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/fluid-ink-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :---------- | :---------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML tag to render. | | `splitBy` | `"word" \| "char"` | `"char"` | Split mode. | | `startBlur` | `string` | `"12px"` | Initial blur filter value. | | `duration` | `number` | `1.4` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.08` | Stagger timing between items. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 90%"` | ScrollTrigger start position. | ## 3. Core Component Source **File Path:** `registry/ui/fluid-ink-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface FluidInkRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" startBlur?: string duration?: number delay?: number stagger?: number viewportOnce?: boolean triggerStart?: string } export const FluidInkReveal = React.forwardRef< HTMLElement, FluidInkRevealProps >( ( { text, as = "h1", className, splitBy = "char", startBlur = "12px", duration = 1.4, delay = 0, stagger = 0.08, viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) const matrixRef = React.useRef(null) const filterId = React.useId() React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".ink-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { const tl = gsap.timeline({ scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, }) const matrixProxy = { a: 18, b: -7 } tl.set(containerRef.current, { filter: `url(#goo-${filterId})` }) tl.set(matrixProxy, { a: 18, b: -7 }) tl.fromTo( elements, { opacity: 0, filter: `blur(${startBlur})`, scale: 1.1, }, { opacity: 1, filter: "blur(0px)", scale: 1, duration, delay, stagger, ease: "power2.inOut", force3D: true, } ) tl.to( matrixProxy, { a: 1, b: 0, duration: 0.6, ease: "power2.out", onUpdate: () => { if (matrixRef.current) { matrixRef.current.setAttribute( "values", `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${matrixProxy.a} ${matrixProxy.b}` ) } }, }, "-=0.6" ) tl.set(containerRef.current, { filter: "none" }) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ startBlur, duration, delay, stagger, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, filter: `blur(${startBlur})`, transform: "scale(1.1)", willChange: "opacity, filter, transform", } const words = text.split(/(\s+)/) const Component = as as any return ( <> ) } ) FluidInkReveal.displayName = "FluidInkReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { FluidInkReveal } from "@/registry/ui/fluid-ink-reveal" export default function ExamplePage() { return (
) } ``` --- # Fluid Typewriter Component Context **Description:** A liquid-smooth typewriter effect for Satis UI. A glowing cursor seamlessly glides across the text, intelligently wrapping to new lines and pausing at punctuation, while characters emerge from a deep blur. Includes robust window resize calculation and clearProps filtering for pristine anti-aliasing. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/fluid-typewriter.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :---------------- | :------------------ | :------------- | :------------------------------------------ | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `cursorClassName` | `string` | `"bg-primary"` | Classes for the cursor. | | `cursorYOffset` | `string \| number` | `"0.1em"` | Downward offset. | | `baseSpeed` | `number` | `0.02` | Gliding speed base (seconds). | | `variance` | `number` | `0.02` | Random speed variance for realistic typing. | | `delay` | `number` | `0` | Intro delay. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 90%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/fluid-typewriter.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface FluidTypewriterProps extends React.HTMLAttributes { text: string as?: React.ElementType cursorClassName?: string cursorYOffset?: string | number baseSpeed?: number variance?: number delay?: number viewportOnce?: boolean triggerStart?: string } export const FluidTypewriter = React.forwardRef< HTMLElement, FluidTypewriterProps >( ( { text, as = "h1", className, cursorClassName = "bg-primary", cursorYOffset = "0.1em", baseSpeed = 0.02, variance = 0.02, delay = 0, viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) const cursorRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current || !cursorRef.current) return const charElements = gsap.utils.toArray( ".fluid-char", containerRef.current ) if (charElements.length === 0) return let tl: gsap.core.Timeline const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { const cursorBlink = gsap.fromTo( cursorRef.current, { opacity: 1 }, { opacity: 0, duration: 0.6, ease: "power2.inOut", repeat: -1, yoyo: true, } ) cursorBlink.pause() tl = gsap.timeline({ delay: delay, scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, onStart: () => { gsap.set(cursorRef.current, { x: charElements[0].offsetLeft, y: charElements[0].offsetTop, opacity: 1, display: "inline-block", }) }, onComplete: () => { cursorBlink.play() }, }) let timePos = 0 charElements.forEach((charNode, i) => { const charText = charNode.getAttribute("data-char") || "" const isLast = i === charElements.length - 1 let nextX, nextY if (isLast) { nextX = charNode.offsetLeft + charNode.offsetWidth nextY = charNode.offsetTop } else { nextX = charElements[i + 1].offsetLeft nextY = charElements[i + 1].offsetTop } const isLineBreak = nextY > charNode.offsetTop + 5 let duration = baseSpeed + Math.random() * variance if (isLineBreak) { duration = 0.15 } else if (charText === " ") { duration = baseSpeed * 1.5 } tl.to( cursorRef.current, { x: nextX, y: nextY, duration: duration, ease: isLineBreak ? "power2.inOut" : "none", }, timePos ) tl.to( charNode, { opacity: 1, filter: "blur(0px)", duration: 0.4, ease: "power2.out", clearProps: "filter", }, timePos ) timePos += duration if (/[.,!?]/.test(charText)) { timePos += 0.25 } }) const handleResize = () => { if (!containerRef.current || !cursorRef.current) return if (tl.progress() > 0 && tl.progress() < 1) { tl.progress(1) } const last = charElements[charElements.length - 1] if (last) { gsap.set(cursorRef.current, { x: last.offsetLeft + last.offsetWidth, y: last.offsetTop, }) } } window.addEventListener("resize", handleResize) return () => window.removeEventListener("resize", handleResize) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.set(cursorRef.current, { display: "none" }) gsap.fromTo( charElements, { opacity: 0, filter: "none" }, { opacity: 1, duration: 0.5, stagger: 0.02, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [baseSpeed, variance, delay, triggerStart, viewportOnce], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, filter: "blur(8px)", willChange: "opacity, filter", } const words = text.split(/(\\s+)/) const Component = as as any return ( ) } ) FluidTypewriter.displayName = "FluidTypewriter" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { FluidTypewriter } from "@/registry/ui/fluid-typewriter" export default function ExamplePage() { return (
) } ``` --- # Fold Reveal Component Context **Description:** A structural text reveal component for Satis UI. Distinct lines of text hinge downward into view like a cascading staircase or folding paper, providing a rigid, architectural feel. Implements clearProps DOM cleanup and strict vestibular failsafes. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/fold-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :---------- | :-------------------------------------------- | | `text` | `string` | _Required_ | Text string. Use `\n` to split folding lines. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `startAngleX` | `number` | `-90` | Starting X-axis rotation. | | `duration` | `number` | `1.2` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.15` | Stagger timing between items. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 85%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/fold-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface FoldRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType startAngleX?: number duration?: number delay?: number stagger?: number viewportOnce?: boolean triggerStart?: string } export const FoldReveal = React.forwardRef< HTMLElement, FoldRevealProps >( ( { text, as = "h1", className, startAngleX = -90, duration = 1.2, delay = 0, stagger = 0.15, viewportOnce = true, triggerStart = "top 85%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".fold-panel", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { rotateX: startAngleX, opacity: 0, }, { rotateX: 0, opacity: 1, duration, delay, stagger, ease: "back.out(1.2)", force3D: true, clearProps: "transform,opacity", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ text, duration, delay, stagger, startAngleX, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: `rotateX(${startAngleX}deg)`, transformOrigin: "bottom center", transformStyle: "preserve-3d", willChange: "transform, opacity", } const lines = text.split("\n") const Component = as as any return ( ) } ) FoldReveal.displayName = "FoldReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { FoldReveal } from "@/registry/ui/fold-reveal" export default function ExamplePage() { return (
) } ``` --- # Glass Slices Component Context **Description:** An interactive WebGL component splitting media into vertical glass slices that dynamically tilt, compress, and refract based on cursor proximity. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/glass-slices.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :---------------- | :------------------- | :-------- | :------------------------------------------------------------------------- | | `mediaUrl` | `string` | — | The source URL of the media asset. | | `mediaType` | `"image" \| "video"` | `"image"` | Defines the media type for correct texture processing. | | `slices` | `number` | `24` | Number of vertical glass slices. | | `hoverRadius` | `number` | `0.25` | Radius of the hover wave (normalized 0.0 to 1.0). | | `minSliceWidth` | `number` | `0.55` | How compressed/tilted the slice gets at the peak of the wave (0.0 to 1.0). | | `shiftY` | `number` | `0.1` | How far the slice shifts down on the Y-axis when hovered. | | `imageZoom` | `number` | `1.15` | How much the image internally zooms in when the slice tilts. | | `mouseLerpSpeed` | `number` | `3.0` | Fluidity of the mouse tracking. Lower = heavier/slower drag. | | `enterLeaveSpeed` | `number` | `2.0` | Speed at which the effect fades in/out when entering/leaving. | | `fallback` | `ReactNode` | `null` | Optional fallback UI rendered via Suspense while media loads. | ## 3. Core Component Source **File Path:** `registry/ui/glass-slices.tsx` ```tsx "use client" import React, { useMemo, useRef, Suspense } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { Html, useTexture, useVideoTexture } from "@react-three/drei" import * as THREE from "three" import { cn } from "@/lib/utils" export interface GlassSlicesProps { mediaUrl: string mediaType?: "image" | "video" slices?: number hoverRadius?: number minSliceWidth?: number shiftY?: number imageZoom?: number mouseLerpSpeed?: number enterLeaveSpeed?: number className?: string fallback?: React.ReactNode } const vertexShader = ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` const fragmentShader = ` uniform sampler2D u_image; uniform float u_slices; uniform vec2 u_mouse; uniform float u_hoverRadius; uniform float u_minSliceWidth; uniform float u_shiftY; uniform float u_imageZoom; uniform vec2 u_resolution; uniform vec2 u_imageRes; uniform float u_active; varying vec2 vUv; void main() { // 1. Slice Grid Logic float sliceId = floor(vUv.x * u_slices); float localX = fract(vUv.x * u_slices); // 0.0 to 1.0 inside the current slice float sliceCenter = (sliceId + 0.5) / u_slices; // 2. Kinematic Proximity (X-axis wave) float distX = abs(sliceCenter - u_mouse.x); float rawInfluence = smoothstep(u_hoverRadius, 0.0, distX); float influence = pow(rawInfluence, 1.5) * u_active; // 3. Compression / Tilt Math float currentWidth = mix(1.0, u_minSliceWidth, influence); float nx = abs(localX * 2.0 - 1.0); float mask = 1.0 - smoothstep(currentWidth, currentWidth + 0.02, nx); // 4. Parallax UV Shifting vec2 sampleUv = vUv; sampleUv.y += influence * u_shiftY; vec2 scaleCenter = vec2(sliceCenter, 0.5); sampleUv = (sampleUv - scaleCenter) * mix(1.0, 1.0 / u_imageZoom, influence) + scaleCenter; // 5. Object-Cover Math vec2 ratio = u_resolution / u_imageRes; float coverRatio = max(ratio.x, ratio.y); vec2 renderSize = u_imageRes * coverRatio; vec2 offset = (u_resolution - renderSize) * 0.5; vec2 coverUv = (sampleUv * u_resolution - offset) / renderSize; vec4 texColor = texture2D(u_image, coverUv); // 6. Volumetric Lighting & Shadows float visibleX = (localX - (0.5 - currentWidth * 0.5)) / currentWidth; float highlight = smoothstep(0.0, 0.1, visibleX) * smoothstep(0.3, 0.1, visibleX); texColor.rgb += highlight * influence * 0.65; float shadow = smoothstep(0.6, 1.0, visibleX); texColor.rgb -= shadow * influence * 0.7; gl_FragColor = vec4(texColor.rgb, texColor.a * mask); } ` interface SlicesRendererProps extends Omit { texture: THREE.Texture } const SlicesRenderer = ({ texture, slices = 24, hoverRadius = 0.25, minSliceWidth = 0.55, shiftY = 0.1, imageZoom = 1.15, mouseLerpSpeed = 3.0, enterLeaveSpeed = 2.0, }: SlicesRendererProps) => { const materialRef = useRef(null) const { size, viewport } = useThree() const targetMouse = useRef(new THREE.Vector2(0.5, 0.5)) const activeState = useRef(0) const uniforms = useMemo(() => { const img = texture.image as HTMLImageElement | HTMLVideoElement | null let width = 1 let height = 1 if (img) { if ("videoWidth" in img) { width = img.videoWidth height = img.videoHeight } else { width = img.naturalWidth || img.width height = img.naturalHeight || img.height } } return { u_image: { value: texture }, u_slices: { value: slices }, u_mouse: { value: new THREE.Vector2(0.5, 0.5) }, u_hoverRadius: { value: hoverRadius }, u_minSliceWidth: { value: minSliceWidth }, u_shiftY: { value: shiftY }, u_imageZoom: { value: imageZoom }, u_resolution: { value: new THREE.Vector2(1, 1) }, u_imageRes: { value: new THREE.Vector2(width, height) }, u_active: { value: 0.0 }, } }, [texture]) useFrame((state, delta) => { if (!materialRef.current) return const dt = Math.min(delta, 0.1) const mx = state.pointer.x * 0.5 + 0.5 const my = state.pointer.y * 0.5 + 0.5 targetMouse.current.set(mx, my) materialRef.current.uniforms.u_mouse.value.lerp( targetMouse.current, Math.min(dt * mouseLerpSpeed, 1.0) ) materialRef.current.uniforms.u_active.value = THREE.MathUtils.lerp( materialRef.current.uniforms.u_active.value, activeState.current, Math.min(dt * enterLeaveSpeed, 1.0) ) materialRef.current.uniforms.u_resolution.value.set(size.width, size.height) materialRef.current.uniforms.u_slices.value = slices materialRef.current.uniforms.u_hoverRadius.value = hoverRadius materialRef.current.uniforms.u_minSliceWidth.value = minSliceWidth materialRef.current.uniforms.u_shiftY.value = shiftY materialRef.current.uniforms.u_imageZoom.value = imageZoom }) return ( (activeState.current = 1)} onPointerLeave={() => (activeState.current = 0)} onPointerCancel={() => (activeState.current = 0)} onPointerOut={() => (activeState.current = 0)} > ) } const ImageScene = ({ mediaUrl, ...props }: { mediaUrl: string } & Partial) => { const texture = useTexture(mediaUrl) return } const VideoScene = ({ mediaUrl, ...props }: { mediaUrl: string } & Partial) => { const texture = useVideoTexture(mediaUrl, { crossOrigin: "Anonymous", muted: true, loop: true, start: true, }) return } export default function GlassSlices({ mediaUrl, mediaType = "image", slices = 24, hoverRadius = 0.25, minSliceWidth = 0.55, shiftY = 0.1, imageZoom = 1.15, mouseLerpSpeed = 3.0, enterLeaveSpeed = 2.0, className, fallback, }: GlassSlicesProps) { return (
{fallback} : null}> {mediaType === "video" ? ( ) : ( )}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import GlassSlices from "@/registry/ui/glass-slices" export default function ExamplePage() { return (
Loading image...
} />
) } ``` --- # Granular Dust Reveal Component Context **Description:** A cinematic text reveal component for Satis UI. Uses microscopic SVG fractal noise to shatter typography into granular sand, dynamically coalescing into pristine, anti-aliased text. Features synchronized SVG-to-native anti-aliasing transitions to ensure flawless sub-pixel rendering upon completion. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/granular-dust-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :--------------------- | :------------------ | :---------- | :----------------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML tag to render. | | `splitBy` | `"word" \| "char"` | `"word"` | Split mode. | | `startingDisplacement` | `number` | `80` | Intensity of the static/dust effect. | | `duration` | `number` | `1.5` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.04` | Stagger timing between items. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 85%"` | ScrollTrigger start position. | ## 3. Core Component Source **File Path:** `registry/ui/granular-dust-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface GranularDustRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" duration?: number delay?: number stagger?: number startingDisplacement?: number viewportOnce?: boolean triggerStart?: string } export const GranularDustReveal = React.forwardRef< HTMLElement, GranularDustRevealProps >( ( { text, as = "h1", className, splitBy = "word", duration = 1.5, delay = 0, stagger = 0.04, startingDisplacement = 80, viewportOnce = true, triggerStart = "top 85%", ...props }, ref ) => { const containerRef = React.useRef(null) const mapRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) const uniqueId = React.useId().replace(/[^a-zA-Z0-9]/g, "") const filterId = `granular-dust-${uniqueId}` useGSAP( () => { if (!containerRef.current || !mapRef.current) return const elements = gsap.utils.toArray( ".dust-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { const tl = gsap.timeline({ scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, onUpdate: function () { if (!containerRef.current) return if (this.progress() === 1) { containerRef.current.style.filter = "none" } else { containerRef.current.style.filter = `url(#${filterId})` } }, }) const totalSequenceDuration = duration + elements.length * stagger tl.fromTo( mapRef.current, { attr: { scale: startingDisplacement } }, { attr: { scale: 0 }, duration: totalSequenceDuration, ease: "power3.out", delay, }, 0 ) tl.fromTo( elements, { opacity: 0, scale: () => gsap.utils.random(1.05, 1.2), y: () => gsap.utils.random(-15, 15), }, { opacity: 1, scale: 1, y: 0, duration, delay, stagger, ease: "power3.out", force3D: true, }, 0 ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ duration, delay, stagger, startingDisplacement, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, willChange: "transform, opacity", display: "inline-block", } const words = text.split(/(\s+)/) const Component = as as any return ( <> ) } ) GranularDustReveal.displayName = "GranularDustReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { GranularDustReveal } from "@/registry/ui/granular-dust-reveal" export default function ExamplePage() { return (
) } ``` --- # Heat Mirage Reveal Component Context **Description:** A cinematic atmospheric reveal component for Satis UI. Uses an SVG displacement map to simulate atmospheric thermal distortion (heat waves). The text drifts upward and materializes as the heat dissipates into sharp focus. Automatically resolves SVG filtering to native browser anti-aliasing to prevent blur artifacts. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/heat-mirage-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :--------------------- | :------------------ | :---------- | :------------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `splitBy` | `"word" \| "char"` | `"char"` | Split mode. | | `duration` | `number` | `2.5` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.08` | Stagger timing between items. | | `startingDisplacement` | `number` | `35` | Intensity of the thermal waving. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 85%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/heat-mirage-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface HeatMirageRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" duration?: number delay?: number stagger?: number startingDisplacement?: number viewportOnce?: boolean triggerStart?: string } export const HeatMirageReveal = React.forwardRef< HTMLElement, HeatMirageRevealProps >( ( { text, as = "h1", className, splitBy = "char", duration = 2.5, delay = 0, stagger = 0.08, startingDisplacement = 35, viewportOnce = true, triggerStart = "top 85%", ...props }, ref ) => { const containerRef = React.useRef(null) const mapRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) const uniqueId = React.useId().replace(/:/g, "") const filterId = `heat-mirage-${uniqueId}` useGSAP( () => { if (!containerRef.current || !mapRef.current) return const elements = gsap.utils.toArray( ".mirage-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { const tl = gsap.timeline({ scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, onUpdate: function () { if (!containerRef.current) return if (this.progress() === 1) { containerRef.current.style.filter = "none" } else { containerRef.current.style.filter = `url(#${filterId})` } }, }) const totalStaggerTime = duration + elements.length * stagger tl.fromTo( mapRef.current, { attr: { scale: startingDisplacement } }, { attr: { scale: 0 }, duration: totalStaggerTime, ease: "power2.out", delay, }, 0 ) tl.fromTo( elements, { opacity: 0, y: 20, scale: 1.05, }, { opacity: 1, y: 0, scale: 1, duration: duration * 0.8, delay, stagger, ease: "power2.out", force3D: true, clearProps: "transform,opacity,scale", }, 0 ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ text, duration, delay, stagger, startingDisplacement, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, willChange: "transform, opacity", display: "inline-block", } const words = text.split(/(\s+)/) const Component = as as any return ( <> ) } ) HeatMirageReveal.displayName = "HeatMirageReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { HeatMirageReveal } from "@/registry/ui/heat-mirage-reveal" export default function ExamplePage() { return (
) } ``` --- # Kaleidoscope Trail Component Context **Description:** A geometrically mapped mouse trail that perfectly mirrors spawned elements around the center axis, creating mesmerizing kaleidoscope mandalas. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/kaleidoscope-trail.json ``` **Dependencies installed:** `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :-------------- | :--------- | :------ | :------------------------------------------------------------------------ | | `imageUrls` | `string[]` | `[]` | Array of image URLs to randomly spawn. | | `distance` | `number` | `40` | Distance the pointer must move (in pixels) before spawning a new cluster. | | `duration` | `number` | `2000` | The total lifespan of a spawned item in milliseconds. | | `maxGroups` | `number` | `12` | Maximum number of clusters on screen before forcing the oldest to exit. | | `mirrors` | `number` | `6` | How many reflections to create around the center (e.g., 6 = Hexagon). | | `itemSize` | `number` | `90` | The base pixel size of the image cards. | | `className` | `string` | `""` | Optional standard Tailwind classes for the wrapper. | | `itemClassName` | `string` | `""` | Optional standard Tailwind classes for the individual image cards. | ## 3. Core Component Source **File Path:** `registry/ui/kaleidoscope-trail.tsx` ```tsx "use client" import { cn } from "@/lib/utils" import { useEffect, useRef } from "react" export interface KaleidoscopeTrailProps { imageUrls?: string[] distance?: number duration?: number maxGroups?: number mirrors?: number itemSize?: number className?: string itemClassName?: string } interface TileData { active: boolean x: number y: number baseRotation: number imageIndex: number zIndex: number t: number state: "enter" | "hold" | "exit" holdTime: number spawnTime: number } export default function KaleidoscopeTrail({ imageUrls = [], distance = 40, duration = 2000, maxGroups = 12, mirrors = 6, itemSize = 90, className, itemClassName = "", }: KaleidoscopeTrailProps) { const reqRef = useRef(null) const DOM_POOL_SIZE = maxGroups * mirrors * 3 const pool = useRef( Array.from({ length: DOM_POOL_SIZE }, () => ({ active: false, x: 0, y: 0, baseRotation: 0, imageIndex: 0, zIndex: 0, t: 0, state: "enter", holdTime: 0, spawnTime: 0, })) ) const domRefs = useRef<(HTMLDivElement | null)[]>([]) const state = useRef({ lastDropPos: { x: -1000, y: -1000 }, spawnCount: 0, lastFrameTime: 0, }) const config = useRef({ imageUrls, distance, maxGroups, duration, itemSize, mirrors }) useEffect(() => { config.current = { imageUrls, distance, maxGroups, duration, itemSize, mirrors } }, [imageUrls, distance, maxGroups, duration, itemSize, mirrors]) useEffect(() => { const handlePointerMove = (e: PointerEvent) => { const s = state.current const c = config.current if (c.imageUrls.length === 0) return const dy = e.clientY - s.lastDropPos.y const dx = e.clientX - s.lastDropPos.x const moveDist = Math.hypot(dx, dy) if (moveDist >= c.distance) { const activeCards = pool.current.filter( (p) => p.active && p.state !== "exit" ) if (activeCards.length >= c.maxGroups * c.mirrors) { activeCards.sort((a, b) => a.spawnTime - b.spawnTime) for (let i = 0; i < c.mirrors; i++) { if (activeCards[i]) activeCards[i].state = "exit" } } const cx = window.innerWidth / 2 const cy = window.innerHeight / 2 const mx = e.clientX - cx const my = e.clientY - cy const radius = Math.hypot(mx, my) const baseAngle = Math.atan2(my, mx) s.lastDropPos = { x: e.clientX, y: e.clientY } s.spawnCount += 1 const spawnTime = Date.now() const imageIndex = s.spawnCount % c.imageUrls.length for (let i = 0; i < c.mirrors; i++) { const freeIndex = pool.current.findIndex((p) => !p.active) if (freeIndex !== -1) { const angleOffset = (i * 2 * Math.PI) / c.mirrors const finalAngle = baseAngle + angleOffset const symX = cx + radius * Math.cos(finalAngle) const symY = cy + radius * Math.sin(finalAngle) const rotationDeg = finalAngle * (180 / Math.PI) + 90 pool.current[freeIndex] = { active: true, x: symX, y: symY, baseRotation: rotationDeg, imageIndex, zIndex: s.spawnCount, t: 0, state: "enter", holdTime: 0, spawnTime, } } } } } window.addEventListener("pointermove", handlePointerMove) return () => window.removeEventListener("pointermove", handlePointerMove) }, []) useEffect(() => { state.current.lastFrameTime = Date.now() const animate = () => { const c = config.current const currentTime = Date.now() const delta = Math.min(currentTime - state.current.lastFrameTime, 32) state.current.lastFrameTime = currentTime const enterDuration = c.duration * 0.15 const holdDuration = c.duration * 0.55 const exitDuration = c.duration * 0.3 for (let i = 0; i < DOM_POOL_SIZE; i++) { const item = pool.current[i] const domNode = domRefs.current[i] if (!domNode) continue if (!item.active) { domNode.style.display = "none" continue } if (item.state === "enter") { item.t += delta / enterDuration if (item.t >= 1) { item.t = 1 item.state = "hold" } } else if (item.state === "hold") { item.holdTime += delta if (item.holdTime >= holdDuration) { item.state = "exit" } } else if (item.state === "exit") { item.t -= delta / exitDuration if (item.t <= 0) { item.t = 0 item.active = false domNode.style.display = "none" continue } } let scale = 1 let opacity = 1 let currentRotation = item.baseRotation if (item.state === "enter") { const easeOut = 1 - Math.pow(1 - item.t, 3) scale = easeOut opacity = easeOut } else if (item.state === "hold") { const p = item.holdTime / holdDuration scale = 1 + Math.sin(p * Math.PI) * 0.1 currentRotation = item.baseRotation + p * 45 opacity = 1 } else if (item.state === "exit") { const easeIn = item.t * item.t scale = easeIn currentRotation = item.baseRotation + 45 + (1 - item.t) * 90 opacity = item.t } domNode.style.display = "flex" domNode.style.zIndex = item.zIndex.toString() domNode.style.opacity = opacity.toString() domNode.style.transform = ` translate3d(${item.x}px, ${item.y}px, 0) rotate(${currentRotation}deg) scale(${scale}) ` const imagesInside = domNode.querySelectorAll("img") imagesInside.forEach((img, idx) => { img.style.display = idx === item.imageIndex ? "block" : "none" }) } reqRef.current = requestAnimationFrame(animate) } reqRef.current = requestAnimationFrame(animate) return () => { if (reqRef.current) cancelAnimationFrame(reqRef.current) } }, [DOM_POOL_SIZE]) useEffect(() => { imageUrls.forEach((src) => { const img = new Image() img.crossOrigin = "anonymous" img.referrerPolicy = "no-referrer" img.src = src }) }, [imageUrls]) return (
{Array.from({ length: DOM_POOL_SIZE }).map((_, i) => (
{ domRefs.current[i] = el }} className={cn( "absolute top-0 left-0 overflow-hidden bg-transparent p-0 drop-shadow-2xl will-change-transform", itemClassName )} style={{ width: `${itemSize}px`, height: `${itemSize}px`, marginLeft: `-${itemSize / 2}px`, marginTop: `-${itemSize / 2}px`, borderRadius: "35%", display: "none", }} > {imageUrls.map((src, imgIndex) => ( trail ))}
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import KaleidoscopeTrail from "@/registry/ui/kaleidoscope-trail" const trailImages = [ "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1780746659/ui-v3/avatars/color/16.png", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1780746659/ui-v3/avatars/color/17.png", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1780746659/ui-v3/avatars/color/18.png", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1780746659/ui-v3/avatars/color/19.png", ] export default function ExamplePage() { return (

Symmetry.

) } ``` --- # Liquid Curtain Component Context **Description:** An interactive image transition component for Satis UI. Applies a heavily asymmetrical SVG Gooey filter to a grid of vertical strips. On hover, the strips calculate their proximity to the cursor and drip downward like thick paint or liquid, revealing the content underneath. Utilizes GSAP `contextSafe` for strict memory management and ARIA presentation standards for screen readers. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/liquid-curtain.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :-------------- | :---------- | :--------------- | :------------------------------- | | `imageUrl` | `string` | _Required_ | The top image URL. | | `children` | `ReactNode` | _Required_ | Reveal content. | | `columns` | `number` | `18` | Vertical strips count. | | `duration` | `number` | `1.2` | Drip animation duration. | | `staggerAmount` | `number` | `0.6` | Wave completion time allocation. | | `ease` | `string` | `"power2.inOut"` | Easing curve. | ## 3. Core Component Source **File Path:** `registry/ui/liquid-curtain.tsx` ```tsx "use client" import React, { useRef, useMemo, useId } from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { cn } from "@/lib/utils" export interface LiquidCurtainProps extends React.HTMLAttributes { imageUrl: string children: React.ReactNode columns?: number duration?: number staggerAmount?: number ease?: string className?: string } export function LiquidCurtain({ imageUrl, children, columns = 18, duration = 1.2, staggerAmount = 0.6, ease = "power2.inOut", className, ...props }: LiquidCurtainProps) { const containerRef = useRef(null) const tl = useRef(null) const rawId = useId() const filterId = useMemo(() => `liquid-${rawId.replace(/:/g, "")}`, [rawId]) const patternId = useMemo(() => `liquid-pat-${rawId.replace(/:/g, "")}`, [rawId]) const strips = useMemo(() => { return Array.from({ length: columns }).map((_, i) => ({ id: i, x: (i / columns) * 100, width: 100 / columns, })) }, [columns]) const { contextSafe } = useGSAP({ scope: containerRef }) const handleMouseEnter = contextSafe((e: React.MouseEvent) => { if (!containerRef.current) return if (tl.current && tl.current.progress() > 0 && tl.current.progress() < 1) { tl.current.play() return } const rect = containerRef.current.getBoundingClientRect() const x = e.clientX - rect.left const colWidthPx = rect.width / columns const col = Math.max(0, Math.min(columns - 1, Math.floor(x / colWidthPx))) const stripElements = gsap.utils.toArray(".liquid-strip", containerRef.current) if (tl.current) tl.current.kill() tl.current = gsap.timeline() tl.current.to(stripElements, { yPercent: () => gsap.utils.random(110, 150), scaleY: () => gsap.utils.random(0.1, 0.4), transformOrigin: "50% 100%", duration: duration, stagger: { amount: staggerAmount, from: col, }, ease: ease, force3D: true, }) }) const handleMouseLeave = contextSafe(() => { if (tl.current) { tl.current.reverse() } }) return (
{children}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { LiquidCurtain } from "@/registry/ui/liquid-curtain" export default function ExamplePage() { return (

Tada!

) } ``` --- # Liquid Marble Component Context **Description:** An interactive WebGL component applying a fluid chromatic distortion that clears into a crystal lens around the cursor. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/liquid-marble.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------------- | :------------------- | :-------- | :---------------------------------------------------------------------------- | | `mediaUrl` | `string` | — | The source URL of the media asset. | | `mediaType` | `"image" \| "video"` | `"image"` | Defines the media type for correct texture processing. | | `hoverRadius` | `number` | `0.35` | How wide the clarity lens spreads (0.0 to 1.0). | | `distortionStrength` | `number` | `0.1` | How violently the liquid swirls outside the cursor. | | `noiseScale` | `number` | `3.0` | Scale of the noise. Lower = larger, sweeping waves. Higher = tighter ripples. | | `speed` | `number` | `0.2` | How fast the liquid continuously flows over time. | | `imageZoom` | `number` | `1.2` | Internal zoom to prevent revealing image edges during heavy distortion. | | `mouseLerpSpeed` | `number` | `3.0` | Fluidity of the mouse tracking. Lower = heavier drag. | | `enterLeaveSpeed` | `number` | `1.5` | Speed at which the clarity lens fades in/out. | | `fallback` | `ReactNode` | `null` | Optional fallback UI rendered via Suspense while media loads. | ## 3. Core Component Source **File Path:** `registry/ui/liquid-marble.tsx` ```tsx "use client" import React, { useMemo, useRef, Suspense } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { Html, useTexture, useVideoTexture } from "@react-three/drei" import * as THREE from "three" import { cn } from "@/lib/utils" export interface LiquidMarbleProps { mediaUrl: string mediaType?: "image" | "video" hoverRadius?: number distortionStrength?: number noiseScale?: number speed?: number imageZoom?: number mouseLerpSpeed?: number enterLeaveSpeed?: number className?: string fallback?: React.ReactNode } const vertexShader = ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` const fragmentShader = ` uniform sampler2D u_image; uniform vec2 u_mouse; uniform vec2 u_resolution; uniform vec2 u_imageRes; uniform float u_hoverRadius; uniform float u_distortionStrength; uniform float u_noiseScale; uniform float u_time; uniform float u_imageZoom; uniform float u_active; varying vec2 vUv; // -------------------------------------------------------- // 3D Simplex Noise (The Fluid Physics Engine) // -------------------------------------------------------- vec4 permute(vec4 x){return mod(((x*34.0)+1.0)*x, 289.0);} 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 + 1.0 * C.xxx; vec3 x2 = x0 - i2 + 2.0 * C.xxx; vec3 x3 = x0 - 1.0 + 3.0 * C.xxx; i = mod(i, 289.0 ); 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_ = 1.0/7.0; 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) ) ); } void main() { // 1. Aspect-Ratio Locked Cover Math vec2 ratio = u_resolution / u_imageRes; float coverRatio = max(ratio.x, ratio.y); vec2 renderSize = u_imageRes * coverRatio; vec2 offset = (u_resolution - renderSize) * 0.5; vec2 coverUv = (vUv * u_resolution - offset) / renderSize; // Zoom slightly to hide border bleeding from the distortion vec2 zoomedUv = (coverUv - 0.5) * (1.0 / u_imageZoom) + 0.5; // 2. Proximity Masking (The Clarity Lens) vec2 aspect = vec2(u_resolution.x / u_resolution.y, 1.0); float dist = distance(vUv * aspect, u_mouse * aspect); // 0.0 at the cursor, 1.0 outside the radius float rawMask = smoothstep(0.0, u_hoverRadius, dist); // When inactive, the mask is 1.0 everywhere (fully distorted). // When active, the cursor reveals the clear image. float mask = mix(1.0, pow(rawMask, 1.2), u_active); // 3. Fluid Distortion Math // We sample the noise twice with an offset to get independent X and Y swiping vec2 noiseUv = zoomedUv * u_noiseScale; float nx = snoise(vec3(noiseUv, u_time)); float ny = snoise(vec3(noiseUv + vec2(100.0), u_time)); // Offset for Y vec2 distortion = vec2(nx, ny) * u_distortionStrength * mask; // 4. Chromatic Refraction (Simulating thick, heavy liquid glass) // We offset the RGB channels slightly based on the distortion vector float r = texture2D(u_image, zoomedUv + distortion * 1.04).r; float g = texture2D(u_image, zoomedUv + distortion * 1.00).g; float b = texture2D(u_image, zoomedUv + distortion * 0.96).b; gl_FragColor = vec4(r, g, b, 1.0); } ` interface MarbleRendererProps extends Omit { texture: THREE.Texture } const MarbleRenderer = ({ texture, hoverRadius = 0.35, distortionStrength = 0.1, noiseScale = 3.0, speed = 0.2, imageZoom = 1.2, mouseLerpSpeed = 3.0, enterLeaveSpeed = 1.5, }: MarbleRendererProps) => { const materialRef = useRef(null) const { size, viewport } = useThree() const targetMouse = useRef(new THREE.Vector2(0.5, 0.5)) const smoothMouse = useRef(new THREE.Vector2(0.5, 0.5)) const activeState = useRef(0) const uniforms = useMemo(() => { const img = texture.image as HTMLImageElement | HTMLVideoElement | null let width = 1 let height = 1 if (img) { if ("videoWidth" in img) { width = img.videoWidth height = img.videoHeight } else { width = img.naturalWidth || img.width height = img.naturalHeight || img.height } } return { u_image: { value: texture }, u_mouse: { value: new THREE.Vector2(0.5, 0.5) }, u_resolution: { value: new THREE.Vector2(1, 1) }, u_imageRes: { value: new THREE.Vector2(width, height) }, u_hoverRadius: { value: hoverRadius }, u_distortionStrength: { value: distortionStrength }, u_noiseScale: { value: noiseScale }, u_time: { value: 0.0 }, u_imageZoom: { value: imageZoom }, u_active: { value: 0.0 }, } }, [texture]) useFrame((state, delta) => { if (!materialRef.current) return const dt = Math.min(delta, 0.1) materialRef.current.uniforms.u_time.value += dt * speed targetMouse.current.set(state.pointer.x * 0.5 + 0.5, state.pointer.y * 0.5 + 0.5) smoothMouse.current.lerp(targetMouse.current, Math.min(dt * mouseLerpSpeed, 1.0)) materialRef.current.uniforms.u_mouse.value.copy(smoothMouse.current) materialRef.current.uniforms.u_active.value = THREE.MathUtils.lerp( materialRef.current.uniforms.u_active.value, activeState.current, Math.min(dt * enterLeaveSpeed, 1.0) ) materialRef.current.uniforms.u_resolution.value.set(size.width, size.height) materialRef.current.uniforms.u_hoverRadius.value = hoverRadius materialRef.current.uniforms.u_distortionStrength.value = distortionStrength materialRef.current.uniforms.u_noiseScale.value = noiseScale materialRef.current.uniforms.u_imageZoom.value = imageZoom }) return ( (activeState.current = 1)} onPointerLeave={() => (activeState.current = 0)} onPointerCancel={() => (activeState.current = 0)} onPointerOut={() => (activeState.current = 0)} > ) } const ImageScene = ({ mediaUrl, ...props }: { mediaUrl: string } & Partial) => { const texture = useTexture(mediaUrl) return } const VideoScene = ({ mediaUrl, ...props }: { mediaUrl: string } & Partial) => { const texture = useVideoTexture(mediaUrl, { crossOrigin: "Anonymous", muted: true, loop: true, start: true }) return } export default function LiquidMarble({ mediaUrl, mediaType = "image", hoverRadius = 0.35, distortionStrength = 0.1, noiseScale = 3.0, speed = 0.2, imageZoom = 1.2, mouseLerpSpeed = 3.0, enterLeaveSpeed = 1.5, className, fallback, }: LiquidMarbleProps) { return (
{fallback} : null}> {mediaType === "video" ? ( ) : ( )}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import LiquidMarble from "@/registry/ui/liquid-marble" export default function ExamplePage() { return (
Loading image...
} />
) } ``` --- # Liquid Mercury Reveal Component Context **Description:** An elastic, metallic text reveal component for Satis UI. Elements spawn from inside the previous element's mass, stretching a gooey liquid bridge that elastically snaps into sharp, crisp typography. Handles clean resolution to native font anti-aliasing via dynamic SVG filter removal. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/liquid-mercury-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :---------- | :---------------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `splitBy` | `"word" \| "char"` | `"char"` | Split mode. | | `duration` | `number` | `2.5` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.05` | Stagger timing between items. | | `startingBlur` | `number` | `12` | Initial blur for liquid generation. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 85%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/liquid-mercury-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface LiquidMercuryRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" duration?: number delay?: number stagger?: number startingBlur?: number viewportOnce?: boolean triggerStart?: string } export const LiquidMercuryReveal = React.forwardRef< HTMLElement, LiquidMercuryRevealProps >( ( { text, as = "h1", className, splitBy = "char", duration = 2.5, delay = 0, stagger = 0.05, startingBlur = 12, viewportOnce = true, triggerStart = "top 85%", ...props }, ref ) => { const containerRef = React.useRef(null) const blurRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) const uniqueId = React.useId().replace(/:/g, "") const filterId = `liquid-mercury-${uniqueId}` useGSAP( () => { if (!containerRef.current || !blurRef.current) return const elements = gsap.utils.toArray( ".mercury-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { const tl = gsap.timeline({ scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, onUpdate: function () { if (!containerRef.current) return if (this.progress() === 1) { containerRef.current.style.filter = "none" } else { containerRef.current.style.filter = `url(#${filterId})` } }, }) const totalStaggerTime = duration + (elements.length - 1) * stagger tl.fromTo( blurRef.current, { attr: { stdDeviation: startingBlur } }, { attr: { stdDeviation: 0 }, duration: totalStaggerTime, ease: "power2.out", delay, }, 0 ) tl.fromTo( elements, { opacity: 0, x: -40, scale: 0.8, }, { opacity: 1, x: 0, scale: 1, duration: duration, delay, stagger, ease: "elastic.out(1.2, 0.4)", force3D: true, clearProps: "transform,scale,opacity", }, 0 ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ text, duration, delay, stagger, startingBlur, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, willChange: "transform, opacity", display: "inline-block", } const words = text.split(/(\s+)/) const Component = as as any return ( <> ) } ) LiquidMercuryReveal.displayName = "LiquidMercuryReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { LiquidMercuryReveal } from "@/registry/ui/liquid-mercury-reveal" export default function ExamplePage() { return (
) } ``` --- # Magnetic Snap Reveal Component Context **Description:** A kinetic, elastic text reveal component for Satis UI. Elements start in randomized, chaotic coordinates (rotated, scaled, translated) and magnetically snap into their correct layout positions using heavy spring physics. Includes clearProps rendering fixes and strict vestibular disorder failsafes. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/magnetic-snap-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :---------- | :---------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `splitBy` | `"word" \| "char"` | `"char"` | Split mode. | | `duration` | `number` | `1.2` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.02` | Stagger timing between items. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 85%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/magnetic-snap-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface MagneticSnapRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" duration?: number delay?: number stagger?: number viewportOnce?: boolean triggerStart?: string } export const MagneticSnapReveal = React.forwardRef< HTMLElement, MagneticSnapRevealProps >( ( { text, as = "h1", className, splitBy = "char", duration = 1.2, delay = 0, stagger = 0.02, viewportOnce = true, triggerStart = "top 85%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".magnetic-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { x: () => gsap.utils.random(-40, 40), y: () => gsap.utils.random(-40, 40), rotation: () => gsap.utils.random(-25, 25), scale: () => gsap.utils.random(0.8, 1.2), opacity: 0, }, { x: 0, y: 0, rotation: 0, scale: 1, opacity: 1, duration, delay, stagger, ease: "elastic.out(1.1, 0.4)", force3D: true, clearProps: "transform,scale,rotation,opacity", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ text, duration, delay, stagger, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, willChange: "transform, opacity", display: "inline-block", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) MagneticSnapReveal.displayName = "MagneticSnapReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { MagneticSnapReveal } from "@/registry/ui/magnetic-snap-reveal" export default function ExamplePage() { return (
) } ``` --- # Manifesto Text Reveal Component Context **Description:** A premium scrollytelling text reveal component for Satis UI. Fades text in word-by-word or character-by-character, utilizing scroll momentum and optional DOM pinning to create a cinematic reading experience. Fully accessible and handles FOUC prevention dynamically. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/manifesto-text-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :---------------- | :---------------------- | :------------- | :-------------------------------------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"p"` | HTML element to render. | | `scrub` | `boolean \| number` | `1` | Scroll binding logic. Number dictates seconds of inertia. | | `splitLevel` | `"word" \| "character"` | `"word"` | Split mode. | | `inactiveOpacity` | `number` | `0.2` | Faded state opacity. | | `pin` | `boolean` | `false` | Enables DOM pinning during scroll tracking. | | `triggerStart` | `string` | `"top 80%"` | Scroll trigger start coordinate. | | `triggerEnd` | `string` | `"bottom 50%"` | Scroll trigger end coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/manifesto-text-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface ManifestoTextRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType scrub?: boolean | number splitLevel?: "word" | "character" inactiveOpacity?: number triggerStart?: string triggerEnd?: string pin?: boolean } export const ManifestoTextReveal = React.forwardRef< HTMLElement, ManifestoTextRevealProps >( ( { text, as = "p", className, scrub = 1, splitLevel = "word", inactiveOpacity = 0.2, triggerStart = "top 80%", triggerEnd = "bottom 50%", pin = false, ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) const scrubValue = scrub === true ? 1 : scrub const isScrubbing = scrub !== false useGSAP( () => { if (!containerRef.current) return const targets = gsap.utils.toArray( ".manifesto-target", containerRef.current ) if (targets.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { if (isScrubbing) { gsap.fromTo( targets, { opacity: inactiveOpacity }, { opacity: 1, stagger: 0.1, ease: "none", scrollTrigger: { trigger: containerRef.current, pin: pin, start: triggerStart, end: triggerEnd, scrub: scrubValue, }, } ) } else { gsap.fromTo( targets, { opacity: inactiveOpacity, y: 8, filter: "blur(4px)" }, { opacity: 1, y: 0, filter: "blur(0px)", stagger: splitLevel === "character" ? 0.02 : 0.04, duration: 0.8, ease: "power3.out", clearProps: "filter,transform", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: true, }, } ) } }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( targets, { opacity: inactiveOpacity }, { opacity: 1, stagger: 0.05, ease: "none", scrollTrigger: { trigger: containerRef.current, pin: pin, start: triggerStart, end: isScrubbing ? triggerEnd : undefined, scrub: isScrubbing ? scrubValue : false, once: !isScrubbing, }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ scrub, scrubValue, isScrubbing, splitLevel, inactiveOpacity, triggerStart, triggerEnd, pin, ], } ) const ssrInitialStyles: React.CSSProperties = isScrubbing ? { opacity: inactiveOpacity, willChange: "opacity" } : { opacity: inactiveOpacity, transform: "translateY(8px)", filter: "blur(4px)", willChange: "opacity, transform, filter", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) ManifestoTextReveal.displayName = "ManifestoTextReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { ManifestoTextReveal } from "@/registry/ui/manifesto-text-reveal" export default function ExamplePage() { return (
) } ``` --- # Masked Reveal Component Context **Description:** A sophisticated text reveal component for Satis UI. Wraps elements in a hidden overflow mask and pushes them up into view with a slight, elegant rotation. Includes clearProps rendering fixes, full screen reader support, and robust reduced-motion failsafes. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/masked-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :-------------- | :------------------ | :----------- | :--------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"div"` | HTML element to render. | | `splitBy` | `"word" \| "char"` | `"word"` | Split mode. | | `startOffset` | `string \| number` | `"100%"` | Initial Y offset. | | `startRotation` | `number` | `5` | Initial Z rotation. | | `delay` | `number` | `0` | Intro delay. | | `duration` | `number` | `1.2` | Animation duration. | | `stagger` | `number` | `0.04` | Stagger timing. | | `ease` | `string` | `"expo.out"` | GSAP ease function. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 90%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/masked-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface MaskedRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" startOffset?: string | number startRotation?: number delay?: number duration?: number stagger?: number ease?: string viewportOnce?: boolean triggerStart?: string } export const MaskedReveal = React.forwardRef( ( { text, as = "div", className, splitBy = "word", startOffset = "100%", startRotation = 5, delay = 0, duration = 1.2, stagger = 0.04, ease = "expo.out", viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) const resolveOffset = typeof startOffset === "number" ? `${startOffset}px` : startOffset useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".reveal-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { y: resolveOffset, rotationZ: startRotation, transformOrigin: "top left", }, { y: "0%", rotationZ: 0, duration, stagger, delay, ease, force3D: true, clearProps: "transform", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0, y: resolveOffset, rotationZ: startRotation }, { opacity: 1, y: "0%", rotationZ: 0, duration: 0.5, delay, stagger, ease: "none", clearProps: "transform", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ resolveOffset, startRotation, duration, stagger, delay, ease, viewportOnce, triggerStart, ], } ) const words = text.split(/(\s+)/) const ssrInitialStyles: React.CSSProperties = { willChange: "transform", backfaceVisibility: "hidden", WebkitFontSmoothing: "antialiased", transform: `translateY(${resolveOffset}) rotate(${startRotation}deg)`, transformOrigin: "top left", } const Component = as as any return ( ) } ) MaskedReveal.displayName = "MaskedReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { MaskedReveal } from "@/registry/ui/masked-reveal" export default function ExamplePage() { return (
) } ``` --- # Multi-Color Trail Reveal Component Context **Description:** A highly advanced scrollytelling component for Satis UI. Sweeps a cascading wave of colors across characters, words, or lines, utilizing strict clip-path physics and DOM pinning. Ensures clean font loading execution and dynamic accessibility routing. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/multi-color-trail-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :--------------- | :----------------------------- | :------------------ | :---------------------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"p"` | HTML element to render. | | `splitBy` | `"char" \| "word" \| "line"` | `"char"` | Split mode. | | `edge` | `"hard" \| "liquid" \| "soft"` | `"soft"` | Visual wipe styling. | | `trailColors` | `string[]` | `[...]` | Color steps for the wave. | | `finalClassName` | `string` | `"text-foreground"` | Final resting class color. | | `momentum` | `number \| boolean` | `1.2` | Scroll binding inertia. | | `pin` | `boolean` | `true` | Enforces scroll locking on trigger point. | ## 3. Core Component Source **File Path:** `registry/ui/multi-color-trail-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export type TrailEdgeType = "hard" | "liquid" | "soft" export type TrailSplitType = "char" | "word" | "line" export interface MultiColorTrailRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: TrailSplitType edge?: TrailEdgeType mutedClassName?: string trailColors?: string[] finalClassName?: string trailLength?: number momentum?: number | boolean pin?: boolean triggerStart?: string triggerEnd?: string } export const MultiColorTrailReveal = React.forwardRef< HTMLElement, MultiColorTrailRevealProps >( ( { text, as = "p", className, splitBy = "char", edge = "soft", mutedClassName = "text-muted-foreground/20", trailColors = ["text-primary/40", "text-primary/80"], finalClassName = "text-foreground", trailLength, momentum = 1.2, pin = true, triggerStart = pin ? "center center" : "top 80%", triggerEnd, ...props }, ref ) => { const triggerRef = React.useRef(null) const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) const resolvedTrailLength = trailLength !== undefined ? trailLength : splitBy === "char" ? 12 : splitBy === "word" ? 4 : 1 useGSAP( () => { if (!containerRef.current || !triggerRef.current) return const totalAnimatedLayers = trailColors.length + 1 const layers: HTMLElement[][] = [] for (let i = 0; i < totalAnimatedLayers; i++) { layers.push( gsap.utils.toArray( `.trail-layer-${i}`, containerRef.current ) ) } if (layers[0].length === 0) return if (typeof document !== "undefined") { document.fonts.ready.then(() => { ScrollTrigger.refresh() }) } const mm = gsap.matchMedia() const distanceMultiplier = splitBy === "char" ? 25 : splitBy === "word" ? 60 : 300 const calculatedEnd = pin ? `+=${layers[0].length * distanceMultiplier}` : "bottom 40%" const finalEnd = triggerEnd || calculatedEnd mm.add("(prefers-reduced-motion: no-preference)", () => { const tl = gsap.timeline({ scrollTrigger: { trigger: triggerRef.current, pin: pin, start: triggerStart, end: finalEnd, scrub: momentum, invalidateOnRefresh: true, }, }) const isSoft = edge === "soft" const isLiquid = edge === "liquid" const baseStagger = splitBy === "char" ? 0.05 : splitBy === "word" ? 0.15 : 0.4 const animDuration = isSoft ? baseStagger * 8 : isLiquid ? baseStagger * 4 : baseStagger * 3 const divisor = Math.max(1, totalAnimatedLayers - 1) const liquidKeyframes = [ { clipPath: "polygon(0% 0%, 30% 0%, 50% 50%, 20% 100%, 0% 100%)" }, { clipPath: "polygon(0% 0%, 80% 0%, 60% 50%, 90% 100%, 0% 100%)" }, { clipPath: "polygon(0% 0%, 110% 0%, 110% 50%, 110% 100%, 0% 100%)" }, ] layers.forEach((layerElements, index) => { const delay = index * baseStagger * (resolvedTrailLength / divisor) if (isSoft) { tl.to( layerElements, { opacity: 1, ease: "power1.inOut", stagger: baseStagger, duration: animDuration, force3D: true, }, delay ) } else if (isLiquid) { tl.to( layerElements, { keyframes: liquidKeyframes, ease: "none", stagger: baseStagger, duration: animDuration, force3D: true, }, delay ) } else { tl.to( layerElements, { clipPath: "inset(0% 0% 0% 0%)", ease: "none", stagger: baseStagger, duration: animDuration, force3D: true, }, delay ) } }) }) mm.add("(prefers-reduced-motion: reduce)", () => { layers.forEach((layerElements) => { if (edge === "soft") { gsap.set(layerElements, { opacity: 1 }) } else { gsap.set(layerElements, { clipPath: "inset(0% 0% 0% 0%)" }) } }) }) return () => mm.revert() }, { scope: triggerRef, dependencies: [ momentum, resolvedTrailLength, triggerStart, triggerEnd, pin, text, trailColors, edge, splitBy, ], } ) const getSsrStyle = (edgeType: TrailEdgeType): React.CSSProperties => { const base: React.CSSProperties = { WebkitFontSmoothing: "antialiased", backfaceVisibility: "hidden", transform: "translateZ(0)", } if (edgeType === "soft") { return { ...base, opacity: 0, willChange: "opacity" } } if (edgeType === "liquid") { return { ...base, clipPath: "polygon(0% 0%, 0% 0%, 0% 50%, 0% 100%, 0% 100%)", willChange: "clip-path", } } return { ...base, clipPath: "inset(0% 100% 0% 0%)", willChange: "clip-path", } } const ssrInitialStyle = getSsrStyle(edge) const renderLayeredStack = (content: string, key: string | number) => ( {content} {trailColors.map((colorClass, layerIdx) => ( {content} ))} {content} ) const renderContent = () => { if (splitBy === "line") { return text.split("\n").map((line, idx) => ( {renderLayeredStack(line, idx)} )) } const words = text.split(/(\s+)/) return words.map((word, wordIdx) => { if (word.match(/\s+/)) { return ( {word} ) } if (splitBy === "char") { return ( {word .split("") .map((char, charIdx) => renderLayeredStack(char, `${wordIdx}-${charIdx}`) )} ) } return renderLayeredStack(word, wordIdx) }) } const Component = as as any return (
) } ) MultiColorTrailReveal.displayName = "MultiColorTrailReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { MultiColorTrailReveal } from "@/registry/ui/multi-color-trail-reveal" export default function ExamplePage() { return (
) } ``` --- # Mycelium Network Component Context **Description:** An interactive image transition component for Satis UI. Connects an SVG grid of nodes and edges masked with a mathematical gooey filter. On hover, it calculates the cursor's origin and triggers a radiating physics simulation where edges snap and nodes organically drift outward. Utilizes GSAP `contextSafe` for strict memory management and ARIA presentation standards for screen readers. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/mycelium-network.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------------ | :---------- | :--------- | :---------------------------- | | `imageUrl` | `string` | _Required_ | The top image URL. | | `children` | `ReactNode` | _Required_ | Reveal content. | | `columns` | `number` | `16` | Grid column count. | | `rows` | `number` | `9` | Grid row count. | | `edgeThickness` | `string` | `"20%"` | Web lines starting thickness. | | `nodeRadius` | `string` | `"15%"` | Anchor nodes starting radius. | | `duration` | `number` | `1.2` | Snap animation base duration. | | `staggerMultiplier` | `number` | `0.7` | Outward ripple speed control. | ## 3. Core Component Source **File Path:** `registry/ui/mycelium-network.tsx` ```tsx "use client" import React, { useRef, useMemo, useId } from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { cn } from "@/lib/utils" export interface MyceliumNetworkProps extends React.HTMLAttributes { imageUrl: string children: React.ReactNode columns?: number rows?: number edgeThickness?: string nodeRadius?: string duration?: number staggerMultiplier?: number className?: string } export function MyceliumNetwork({ imageUrl, children, columns = 16, rows = 9, edgeThickness = "20%", nodeRadius = "15%", duration = 1.2, staggerMultiplier = 0.7, className, ...props }: MyceliumNetworkProps) { const containerRef = useRef(null) const tl = useRef(null) const rawId = useId() const filterId = useMemo(() => `mycelium-filter-${rawId.replace(/:/g, "")}`, [rawId]) const maskId = useMemo(() => `mycelium-mask-${rawId.replace(/:/g, "")}`, [rawId]) const { nodes, edges } = useMemo(() => { const nodesArr = [] const edgesArr = [] for (let r = 0; r < rows; r++) { for (let c = 0; c < columns; c++) { const id = r * columns + c const rawCx = (c / (columns - 1)) * 120 - 10 const rawCy = (r / (rows - 1)) * 120 - 10 nodesArr.push({ id: `node-${id}`, rawCx, rawCy, cx: `${rawCx}%`, cy: `${rawCy}%`, }) } } for (let r = 0; r < rows; r++) { for (let c = 0; c < columns; c++) { const idx = r * columns + c const n1 = nodesArr[idx] if (c < columns - 1) { const n2 = nodesArr[idx + 1] edgesArr.push({ id: `edge-r-${idx}`, x1: n1.cx, y1: n1.cy, x2: n2.cx, y2: n2.cy, rawCx: (n1.rawCx + n2.rawCx) / 2, rawCy: (n1.rawCy + n2.rawCy) / 2, }) } if (r < rows - 1) { const n3 = nodesArr[idx + columns] edgesArr.push({ id: `edge-d-${idx}`, x1: n1.cx, y1: n1.cy, x2: n3.cx, y2: n3.cy, rawCx: (n1.rawCx + n3.rawCx) / 2, rawCy: (n1.rawCy + n3.rawCy) / 2, }) } if (c < columns - 1 && r < rows - 1) { const n4 = nodesArr[idx + columns + 1] edgesArr.push({ id: `edge-diag-${idx}`, x1: n1.cx, y1: n1.cy, x2: n4.cx, y2: n4.cy, rawCx: (n1.rawCx + n4.rawCx) / 2, rawCy: (n1.rawCy + n4.rawCy) / 2, }) } } } return { nodes: nodesArr, edges: edgesArr } }, [columns, rows]) const { contextSafe } = useGSAP({ scope: containerRef }) const handleMouseEnter = contextSafe((e: React.MouseEvent) => { if (!containerRef.current) return if (tl.current && tl.current.progress() > 0 && tl.current.progress() < 1) { tl.current.play() return } const rect = containerRef.current.getBoundingClientRect() const cursorX = e.clientX - rect.left const cursorY = e.clientY - rect.top const maxDist = Math.hypot(rect.width, rect.height) const domEdges = gsap.utils.toArray(".mycelium-edge", containerRef.current) const domNodes = gsap.utils.toArray(".mycelium-node", containerRef.current) if (tl.current) tl.current.kill() tl.current = gsap.timeline() tl.current.to( domEdges, { attr: { "stroke-width": 0 }, duration: duration * 0.4, ease: "power2.in", delay: (i, target) => { const cx = (parseFloat(target.dataset.cx) / 100) * rect.width const cy = (parseFloat(target.dataset.cy) / 100) * rect.height const dist = Math.hypot(cx - cursorX, cy - cursorY) return (dist / maxDist) * staggerMultiplier }, }, 0 ) tl.current.to( domNodes, { attr: { r: 0, cy: (i, target) => `${parseFloat(target.dataset.cy) - gsap.utils.random(15, 30)}%`, cx: (i, target) => `${parseFloat(target.dataset.cx) + gsap.utils.random(-15, 15)}%`, }, duration: duration, ease: "power2.out", delay: (i, target) => { const cx = (parseFloat(target.dataset.cx) / 100) * rect.width const cy = (parseFloat(target.dataset.cy) / 100) * rect.height const dist = Math.hypot(cx - cursorX, cy - cursorY) return (dist / maxDist) * staggerMultiplier + 0.15 }, }, 0 ) }) const handleMouseLeave = contextSafe(() => { if (tl.current) { tl.current.reverse() } }) return (
{children}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { MyceliumNetwork } from "@/registry/ui/mycelium-network" export default function ExamplePage() { return (

Tada!

) } ``` --- # Panoramic Carousel Component Context **Description:** A high-performance WebGL spatial carousel for Satis UI. Creates an immersive, 3D panoramic wrap of images that responds fluidly to scroll and touch momentum anywhere on the screen. Features custom GLSL shaders for kinetic RGB splitting, aerodynamic flexing, SDF rounding, and motion blur. Includes a real-time OS reduced-motion check inside the render loop to disable nausea-inducing effects. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/panoramic-carousel.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------------------- | :--------- | :--------- | :-------------------------- | | `images` | `string[]` | _Required_ | Array of image URLs. | | `cardWidthRatio` | `number` | `0.4` | Screen width ratio. | | `cardAspectRatio` | `number` | `1.4` | Width/Height aspect ratio. | | `scrollSensitivity` | `number` | `0.005` | Scroll input multiplier. | | `lerpFactor` | `number` | `0.08` | Smooth momentum decay. | | `radiusMultiplier` | `number` | `1.4` | Camera distance. | | `gapMultiplier` | `number` | `1.1` | Card spacing. | | `depthMultiplier` | `number` | `1.0` | Z-axis curve depth. | | `baseFov` | `number` | `45` | Base field of view. | | `maxFovZoom` | `number` | `35` | Max FOV zoom on scroll. | | `fovMultiplier` | `number` | `1.2` | Camera zoom aggressiveness. | | `motionBlurIntensity` | `number` | `0.003` | 7-tap motion blur strength. | | `chromaticAberrationIntensity` | `number` | `0.005` | Kinetic RGB separation. | | `flexMultiplier` | `number` | `0.15` | Aerodynamic bending. | | `cornerRadius` | `number` | `0.04` | SDF edge rounding. | | `parallaxIntensity` | `number` | `0.08` | Internal image parallax. | ## 3. Core Component Source **File Path:** `registry/ui/panoramic-carousel.tsx` ```tsx "use client" import React, { useRef, useState, useMemo, useEffect, useLayoutEffect } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { useTexture } from "@react-three/drei" import * as THREE from "three" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface PanoramicCarouselProps extends Omit, "children"> { images: string[] cardWidthRatio?: number cardAspectRatio?: number scrollSensitivity?: number lerpFactor?: number radiusMultiplier?: number gapMultiplier?: number depthMultiplier?: number baseFov?: number maxFovZoom?: number fovMultiplier?: number motionBlurIntensity?: number chromaticAberrationIntensity?: number flexMultiplier?: number cornerRadius?: number fadeEdge1?: number fadeEdge2?: number parallaxIntensity?: number } interface ScrollState { targetAngle: number currentAngle: number velocity: number min: number max: number } const PanoramicVertexShader = ` precision mediump float; uniform float uVelocity; uniform float uFlexMultiplier; varying vec2 vUv; void main() { vUv = uv; vec3 pos = position; float distFromCenter = abs(uv.x - 0.5) * 2.0; pos.z += pow(distFromCenter, 2.0) * abs(uVelocity) * uFlexMultiplier; gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } ` const PanoramicFragmentShader = ` precision mediump float; uniform sampler2D uTexture; uniform float uVelocity; uniform float uAngle; uniform vec2 uResolution; uniform float uImageAspect; uniform float uMotionBlurIntensity; uniform float uChromaticAberration; uniform float uCornerRadius; uniform float uFadeEdge1; uniform float uFadeEdge2; uniform float uParallaxIntensity; varying vec2 vUv; void main() { float screenAspect = uResolution.x / uResolution.y; vec2 scale = vec2(1.0); if (screenAspect > uImageAspect) { scale.y = uImageAspect / screenAspect; } else { scale.x = screenAspect / uImageAspect; } vec2 parallaxUv = (vUv - 0.5) * (scale * 0.85) + 0.5; parallaxUv.x += clamp(uAngle, -1.0, 1.0) * uParallaxIntensity; vec4 color = vec4(0.0); float blurAmount = uVelocity * uMotionBlurIntensity; float caAmount = uVelocity * uChromaticAberration; for(float i = -3.0; i <= 3.0; i++) { vec2 offsetUv = parallaxUv + vec2(i * blurAmount, 0.0); float r = texture2D(uTexture, offsetUv + vec2(caAmount, 0.0)).r; float g = texture2D(uTexture, offsetUv).g; float b = texture2D(uTexture, offsetUv - vec2(caAmount, 0.0)).b; color += vec4(r, g, b, 1.0); } color /= 7.0; vec2 pos = vUv - 0.5; vec2 pixelPos = pos * uResolution; vec2 pixelSize = vec2(0.5) * uResolution; float pixelRadius = uCornerRadius * min(uResolution.x, uResolution.y); float dist = length(max(abs(pixelPos) - pixelSize + pixelRadius, 0.0)) - pixelRadius; float cornerAlpha = 1.0 - smoothstep(0.0, 1.5, dist); float fadeAlpha = 1.0 - smoothstep(uFadeEdge1, uFadeEdge2, abs(uAngle)); gl_FragColor = vec4(color.rgb, cornerAlpha * fadeAlpha); } ` function PanoramicScene({ images, scrollState, onReady, cardWidthRatio, cardAspectRatio, lerpFactor, radiusMultiplier, gapMultiplier, depthMultiplier, baseFov, maxFovZoom, fovMultiplier, motionBlurIntensity, chromaticAberrationIntensity, flexMultiplier, cornerRadius, fadeEdge1, fadeEdge2, parallaxIntensity, }: PanoramicCarouselProps & { scrollState: React.MutableRefObject onReady: () => void }) { const textures = useTexture(images) const { viewport, camera } = useThree() const groupRef = useRef(null) const isMobile = viewport.width < 5 let itemWidth = isMobile ? viewport.width * 0.7 : viewport.width * cardWidthRatio! let itemHeight = itemWidth * cardAspectRatio! const maxHeight = viewport.height * (isMobile ? 0.6 : 0.65) if (itemHeight > maxHeight) { itemHeight = maxHeight itemWidth = itemHeight / cardAspectRatio! } const radius = viewport.width * radiusMultiplier! const angleSpacing = (itemWidth / radius) * gapMultiplier! const geometry = useMemo( () => new THREE.PlaneGeometry(itemWidth, itemHeight, 32, 1), [itemWidth, itemHeight] ) const materials = useMemo(() => { return textures.map((texture) => { const img = texture.image as | { width?: number; height?: number } | null | undefined const imageAspect = img?.width && img?.height ? img.width / img.height : 1 return new THREE.ShaderMaterial({ vertexShader: PanoramicVertexShader, fragmentShader: PanoramicFragmentShader, uniforms: { uTexture: { value: texture }, uVelocity: { value: 0 }, uAngle: { value: 0 }, uResolution: { value: new THREE.Vector2(itemWidth, itemHeight) }, uImageAspect: { value: imageAspect }, uMotionBlurIntensity: { value: motionBlurIntensity }, uChromaticAberration: { value: chromaticAberrationIntensity }, uFlexMultiplier: { value: flexMultiplier }, uCornerRadius: { value: cornerRadius }, uFadeEdge1: { value: fadeEdge1 }, uFadeEdge2: { value: fadeEdge2 }, uParallaxIntensity: { value: parallaxIntensity }, }, transparent: true, depthWrite: false, }) }) }, [ textures, itemWidth, itemHeight, motionBlurIntensity, chromaticAberrationIntensity, flexMultiplier, cornerRadius, fadeEdge1, fadeEdge2, parallaxIntensity, ]) useLayoutEffect(() => { return () => { geometry.dispose() materials.forEach((m) => m.dispose()) } }, [geometry, materials]) useEffect(() => { scrollState.current.min = 0 scrollState.current.max = (images.length - 1) * angleSpacing requestAnimationFrame(() => onReady()) }, [images.length, angleSpacing, scrollState, onReady]) useFrame((_, delta) => { const state = scrollState.current const dt = Math.min(delta, 0.1) const isReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches state.targetAngle = THREE.MathUtils.clamp( state.targetAngle, state.min, state.max ) const prevAngle = state.currentAngle state.currentAngle = THREE.MathUtils.damp( state.currentAngle, state.targetAngle, lerpFactor! * 100, dt ) const angleDelta = state.currentAngle - prevAngle const trueVelocity = isReducedMotion ? 0 : angleDelta / dt state.velocity = THREE.MathUtils.damp(state.velocity, trueVelocity, 5, dt) const targetFov = isReducedMotion ? baseFov! : baseFov! + Math.min(Math.abs(state.velocity) * fovMultiplier!, maxFovZoom!) const perspectiveCamera = camera as THREE.PerspectiveCamera perspectiveCamera.fov = THREE.MathUtils.damp( perspectiveCamera.fov, targetFov, 4, dt ) perspectiveCamera.updateProjectionMatrix() if (groupRef.current) { groupRef.current.children.forEach((mesh: any, i) => { const material = materials[i] if (!material) return const angle = i * angleSpacing - state.currentAngle mesh.position.x = Math.sin(angle) * radius mesh.position.z = (1.0 - Math.cos(angle)) * radius * depthMultiplier! mesh.rotation.y = -angle mesh.renderOrder = 1000 + Math.abs(angle) * 100 material.uniforms.uVelocity.value = state.velocity material.uniforms.uAngle.value = angle }) } }) return ( {textures.map((_, i) => ( ))} ) } export const PanoramicCarousel = React.forwardRef< HTMLDivElement, PanoramicCarouselProps >( ( { images, className, cardWidthRatio = 0.4, cardAspectRatio = 1.4, scrollSensitivity = 0.005, lerpFactor = 0.08, radiusMultiplier = 1.4, gapMultiplier = 1.1, depthMultiplier = 1.0, baseFov = 45, maxFovZoom = 35, fovMultiplier = 1.2, motionBlurIntensity = 0.003, chromaticAberrationIntensity = 0.005, flexMultiplier = 0.15, cornerRadius = 0.04, fadeEdge1 = 0.5, fadeEdge2 = 1.5, parallaxIntensity = 0.08, ...props }, ref ) => { const containerRef = useRef(null) const [isLoaded, setIsLoaded] = useState(false) React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement) const scrollState = useRef({ targetAngle: 0, currentAngle: 0, velocity: 0, min: 0, max: 0, }) useGSAP( () => { if (!containerRef.current) return const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onWheel: (e) => { const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY scrollState.current.targetAngle += delta * scrollSensitivity }, onDrag: (e) => { scrollState.current.targetAngle -= e.deltaX * scrollSensitivity }, }) return () => observer.kill() }, { scope: containerRef, dependencies: [scrollSensitivity] } ) return (

Interactive 3D Image Carousel. Scroll to navigate.

{images.map((img, i) => ( {`Slide ))}
) } ) PanoramicCarousel.displayName = "PanoramicCarousel" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { PanoramicCarousel } from "@/registry/ui/panoramic-carousel" export default function ExamplePage() { const images = [ "/image1.jpg", "/image2.jpg", "/image3.jpg" ] return (
) } ``` --- # Pendulum Reveal Component Context **Description:** A kinetic text reveal component for Satis UI. Elements drop down from a top hinge point, utilizing a heavy elastic ease to simulate a swinging pendulum settling into place. Includes `clearProps` anti-aliasing fixes and robust screen reader formatting. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/pendulum-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :---------- | :---------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `splitBy` | `"word" \| "char"` | `"char"` | Split mode. | | `startAngleX` | `number` | `90` | Starting X-axis rotation. | | `startAngleZ` | `number` | `-8` | Starting Z-axis twist. | | `duration` | `number` | `1.6` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.04` | Stagger timing between items. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 90%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/pendulum-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface PendulumRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" startAngleX?: number startAngleZ?: number duration?: number delay?: number stagger?: number viewportOnce?: boolean triggerStart?: string } export const PendulumReveal = React.forwardRef< HTMLElement, PendulumRevealProps >( ( { text, as = "h1", className, splitBy = "char", startAngleX = 90, startAngleZ = -8, duration = 1.6, delay = 0, stagger = 0.04, viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".pendulum-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { rotateX: startAngleX, rotateZ: startAngleZ, y: "-0.3em", opacity: 0, }, { rotateX: 0, rotateZ: 0, y: 0, opacity: 1, duration, delay, stagger, ease: "elastic.out(1.2, 0.4)", force3D: true, clearProps: "transform,opacity", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", clearProps: "transform", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ duration, delay, stagger, startAngleX, startAngleZ, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: `translateY(-0.3em) rotateX(${startAngleX}deg) rotateZ(${startAngleZ}deg)`, transformOrigin: "top center", transformStyle: "preserve-3d", willChange: "transform, opacity", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) PendulumReveal.displayName = "PendulumReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { PendulumReveal } from "@/registry/ui/pendulum-reveal" export default function ExamplePage() { return (
) } ``` --- # Pendulum Trail Component Context **Description:** An interactive mouse trail that maps spawned items to an invisible swinging pendulum axis, driven entirely by pointer velocity and physics length. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/pendulum-trail.json ``` **Dependencies installed:** `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :-------------- | :--------- | :------ | :-------------------------------------------------------------------------- | | `imageUrls` | `string[]` | `[]` | Array of image URLs to randomly spawn. | | `distance` | `number` | `70` | Distance the pointer must move (in pixels) before spawning a new item. | | `duration` | `number` | `3000` | The total lifespan of a spawned item in milliseconds. | | `maxItems` | `number` | `15` | Maximum items on screen before forcing the oldest to gracefully shrink out. | | `itemSize` | `number` | `110` | The base pixel width of the image cards. | | `windAngle` | `number` | `10` | Base horizontal wind angle pushing the cards. | | `windSway` | `number` | `5` | The intensity of the sine-wave flutter applied by the wind. | | `physicsLength` | `number` | `40` | Length of the invisible string. Dictates the speed and arc of the swing. | | `className` | `string` | `""` | Optional standard Tailwind classes for the wrapper. | | `itemClassName` | `string` | `""` | Optional standard Tailwind classes for the individual image cards. | ## 3. Core Component Source **File Path:** `registry/ui/pendulum-trail.tsx` ```tsx "use client" import { cn } from "@/lib/utils" import { useEffect, useRef } from "react" export interface PendulumTrailProps { imageUrls?: string[] distance?: number duration?: number maxItems?: number itemSize?: number windAngle?: number windSway?: number physicsLength?: number className?: string itemClassName?: string } interface TileData { active: boolean id: number x: number y: number vx: number flutterOffset: number imageIndex: number zIndex: number t: number state: "enter" | "hold" | "exit" holdTime: number spawnTime: number } export default function PendulumTrail({ imageUrls = [], distance = 70, duration = 3000, maxItems = 15, itemSize = 110, windAngle = 10, windSway = 5, physicsLength = 40, className, itemClassName = "", }: PendulumTrailProps) { const reqRef = useRef(null) const DOM_POOL_SIZE = maxItems * 3 const pool = useRef( Array.from({ length: DOM_POOL_SIZE }, (_, i) => ({ active: false, id: i, x: 0, y: 0, vx: 0, flutterOffset: 0, imageIndex: 0, zIndex: 0, t: 0, state: "enter", holdTime: 0, spawnTime: 0, })) ) const domRefs = useRef<(HTMLDivElement | null)[]>([]) const state = useRef({ smoothedMouse: { x: -1000, y: -1000 }, actualMouse: { x: -1000, y: -1000 }, lastDropPos: { x: -1000, y: -1000 }, lastMoveTime: 0, spawnCount: 0, lastFrameTime: 0, }) const config = useRef({ imageUrls, distance, duration, maxItems, itemSize, windAngle, windSway, physicsLength }) useEffect(() => { config.current = { imageUrls, distance, duration, maxItems, itemSize, windAngle, windSway, physicsLength } }, [imageUrls, distance, duration, maxItems, itemSize, windAngle, windSway, physicsLength]) useEffect(() => { const handlePointerMove = (e: PointerEvent) => { const s = state.current const c = config.current const now = Date.now() if (c.imageUrls.length === 0) return if (s.actualMouse.x === -1000) { s.actualMouse = { x: e.clientX, y: e.clientY } s.smoothedMouse = { x: e.clientX, y: e.clientY } s.lastDropPos = { x: e.clientX, y: e.clientY } s.lastMoveTime = now return } const moveDx = e.clientX - s.actualMouse.x const dt = Math.max(now - s.lastMoveTime, 1) const vx = moveDx / dt s.actualMouse = { x: e.clientX, y: e.clientY } s.lastMoveTime = now const dropDx = e.clientX - s.lastDropPos.x const dropDy = e.clientY - s.lastDropPos.y const dropDistance = Math.hypot(dropDx, dropDy) if (dropDistance >= c.distance) { const activeNonExiting = pool.current.filter( (p) => p.active && p.state !== "exit" ) if (activeNonExiting.length >= c.maxItems) { activeNonExiting.sort((a, b) => a.spawnTime - b.spawnTime) const oldest = activeNonExiting[0] oldest.state = "exit" oldest.t = 0 } const freeIndex = pool.current.findIndex((p) => !p.active) if (freeIndex !== -1) { s.lastDropPos = { x: e.clientX, y: e.clientY } s.spawnCount += 1 pool.current[freeIndex] = { active: true, id: freeIndex, x: e.clientX, y: e.clientY, vx: vx, flutterOffset: Math.random() * Math.PI * 2, imageIndex: s.spawnCount % c.imageUrls.length, zIndex: s.spawnCount, t: 0, state: "enter", holdTime: 0, spawnTime: Date.now(), } } } } window.addEventListener("pointermove", handlePointerMove) return () => window.removeEventListener("pointermove", handlePointerMove) }, []) useEffect(() => { state.current.lastFrameTime = Date.now() const animate = () => { const c = config.current const currentTime = Date.now() const delta = Math.min(currentTime - state.current.lastFrameTime, 32) state.current.lastFrameTime = currentTime const s = state.current s.smoothedMouse.x += (s.actualMouse.x - s.smoothedMouse.x) * 0.15 s.smoothedMouse.y += (s.actualMouse.y - s.smoothedMouse.y) * 0.15 const enterDuration = c.duration * 0.1 const holdDuration = c.duration * 0.7 const exitDuration = c.duration * 0.2 const currentPhysicsLength = Math.max(c.physicsLength, 15) const swingFrequency = Math.sqrt(640 / currentPhysicsLength) const velocityMultiplier = 60 / Math.sqrt(currentPhysicsLength) for (let i = 0; i < DOM_POOL_SIZE; i++) { const item = pool.current[i] const domNode = domRefs.current[i] if (!domNode) continue if (!item.active) { domNode.style.display = "none" continue } if (item.state === "enter") { item.t += delta / enterDuration if (item.t >= 1) { item.t = 1 item.state = "hold" } } else if (item.state === "hold") { item.holdTime += delta if (item.holdTime >= holdDuration) { item.state = "exit" item.t = 0 } } else if (item.state === "exit") { item.t += delta / exitDuration if (item.t >= 1) { item.t = 1 item.active = false domNode.style.display = "none" continue } } const timeAlive = (currentTime - item.spawnTime) / 1000 const baseAmplitude = item.vx * -velocityMultiplier const amplitude = Math.max(Math.min(baseAmplitude, 60), -60) const damping = 1.8 const pendulumAngle = amplitude * Math.exp(-damping * timeAlive) * Math.cos(swingFrequency * timeAlive) const pendulumDepth = amplitude * Math.exp(-damping * timeAlive) * Math.sin(swingFrequency * timeAlive) const ambientWind = c.windAngle + Math.sin(timeAlive * 1.5 + item.flutterOffset) * c.windSway const finalRotation = pendulumAngle + ambientWind const depthScale = pendulumDepth / 45 let visualScale = Math.max(1 + depthScale * 0.08, 0.5) let opacity = 1 if (item.state === "enter") { const easeOut = Math.sin((item.t * Math.PI) / 2) visualScale *= easeOut opacity = item.t } else if (item.state === "exit") { const easeIn = 1 - item.t visualScale *= easeIn * easeIn opacity = easeIn } domNode.style.display = "flex" domNode.style.zIndex = item.zIndex.toString() domNode.style.opacity = opacity.toString() domNode.style.transform = ` translate3d(${item.x}px, ${item.y}px, 0) rotate(${finalRotation}deg) scale(${visualScale}) ` const imagesInside = domNode.querySelectorAll("img.js-trail-img") imagesInside.forEach((img, idx) => { img.style.display = idx === item.imageIndex ? "block" : "none" }) } reqRef.current = requestAnimationFrame(animate) } reqRef.current = requestAnimationFrame(animate) return () => { if (reqRef.current) cancelAnimationFrame(reqRef.current) } }, [DOM_POOL_SIZE]) useEffect(() => { imageUrls.forEach((src) => { const img = new Image() img.crossOrigin = "anonymous" img.referrerPolicy = "no-referrer" img.src = src }) }, [imageUrls]) return (
{Array.from({ length: DOM_POOL_SIZE }).map((_, i) => (
{ domRefs.current[i] = el }} className={cn( "absolute top-0 left-0 flex flex-col items-center will-change-transform", itemClassName )} style={{ width: `${itemSize}px`, marginLeft: `-${itemSize / 2}px`, transformOrigin: "50% -20px", display: "none", }} >
{imageUrls.map((src, imgIndex) => ( trail ))}
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import PendulumTrail from "@/registry/ui/pendulum-trail" const trailImages = [ "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/14.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/17.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/18.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/19.jpg", ] export default function ExamplePage() { return (

Pendulum.

) } ``` --- # Piano Typewriter Component Context **Description:** A tactile, 3D typewriter effect for Satis UI. A cursor glides across the text while individual characters spring up from the Z-axis, mimicking the physical, mechanical keystrokes of a typewriter or piano. Includes robust window resize calculation and clearProps filtering for pristine rendering. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/piano-typewriter.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :---------------- | :------------------ | :------------- | :------------------------------------------ | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `cursorClassName` | `string` | `"bg-primary"` | Classes for the cursor. | | `baseSpeed` | `number` | `0.04` | Gliding speed base (seconds). | | `variance` | `number` | `0.02` | Random speed variance for realistic typing. | | `delay` | `number` | `0` | Intro delay. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 90%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/piano-typewriter.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface PianoTypewriterProps extends React.HTMLAttributes { text: string as?: React.ElementType cursorClassName?: string baseSpeed?: number variance?: number delay?: number viewportOnce?: boolean triggerStart?: string } export const PianoTypewriter = React.forwardRef< HTMLElement, PianoTypewriterProps >( ( { text, as = "h1", className, cursorClassName = "bg-primary", baseSpeed = 0.04, variance = 0.02, delay = 0, viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) const cursorRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current || !cursorRef.current) return const charContainers = gsap.utils.toArray( ".piano-char-container", containerRef.current ) if (charContainers.length === 0) return let tl: gsap.core.Timeline const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { const cursorBlink = gsap.fromTo( cursorRef.current, { opacity: 1 }, { opacity: 0, duration: 0.6, ease: "power2.inOut", repeat: -1, yoyo: true, } ) cursorBlink.pause() tl = gsap.timeline({ delay: delay, scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, onStart: () => { gsap.set(cursorRef.current, { x: charContainers[0].offsetLeft, y: charContainers[0].offsetTop, opacity: 1, display: "inline-block", }) }, onComplete: () => { cursorBlink.play() }, }) let timePos = 0 charContainers.forEach((container, i) => { const charText = container.getAttribute("data-char") || "" const isLast = i === charContainers.length - 1 let nextX, nextY if (isLast) { nextX = container.offsetLeft + container.offsetWidth nextY = container.offsetTop } else { nextX = charContainers[i + 1].offsetLeft nextY = charContainers[i + 1].offsetTop } const isLineBreak = nextY > container.offsetTop + 5 let duration = baseSpeed + Math.random() * variance if (isLineBreak) duration = 0.15 else if (charText === " ") duration = baseSpeed * 1.5 tl.to( cursorRef.current, { x: nextX, y: nextY, duration: duration, ease: isLineBreak ? "power2.inOut" : "none", }, timePos ) const visibleChar = container.querySelector(".piano-visible") if (visibleChar) { tl.fromTo( visibleChar, { opacity: 0, rotateX: -60, y: 15, scale: 0.8 }, { opacity: 1, rotateX: 0, y: 0, scale: 1, duration: 0.8, ease: "back.out(2.5)", force3D: true, clearProps: "transform,scale,opacity", }, timePos ) } timePos += duration if (/[.,!?]/.test(charText)) { timePos += 0.25 } }) const handleResize = () => { if (!containerRef.current || !cursorRef.current) return if (tl.progress() > 0 && tl.progress() < 1) { tl.progress(1) } const last = charContainers[charContainers.length - 1] if (last) { gsap.set(cursorRef.current, { x: last.offsetLeft + last.offsetWidth, y: last.offsetTop, }) } } window.addEventListener("resize", handleResize) return () => window.removeEventListener("resize", handleResize) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.set(cursorRef.current, { display: "none" }) const visibleChars = gsap.utils.toArray(".piano-visible", containerRef.current) gsap.fromTo( visibleChars, { opacity: 0 }, { opacity: 1, duration: 0.5, stagger: 0.02, ease: "none", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [baseSpeed, variance, delay, triggerStart, viewportOnce], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: "translateY(15px) rotateX(-60deg) scale(0.8)", transformOrigin: "bottom center", willChange: "transform, opacity", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) PianoTypewriter.displayName = "PianoTypewriter" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { PianoTypewriter } from "@/registry/ui/piano-typewriter" export default function ExamplePage() { return (
) } ``` --- # Proximity Grid Component Context **Description:** An interactive WebGL grid that zooms and rounds media cells dynamically based on cursor proximity. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/proximity-grid.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :---------------- | :------------------- | :-------- | :--------------------------------------------------------------------------- | | `mediaUrl` | `string` | — | The source URL of the media asset. | | `mediaType` | `"image" \| "video"` | `"image"` | Defines the media type for correct texture processing. | | `columns` | `number` | `18` | Number of vertical grid columns. | | `rows` | `number` | `12` | Number of horizontal grid rows. | | `hoverRadius` | `number` | `4.0` | How many cells outward the ripple effect reaches. | | `imageZoom` | `number` | `1.25` | How much the image zooms in when the cell is hovered. | | `cellRadius` | `number` | `0.45` | Max border radius of a cell when hovered (0.0 to 0.5 relative to cell size). | | `mouseLerpSpeed` | `number` | `2.5` | Fluidity of the mouse tracking. Lower = heavier/slower drag. | | `enterLeaveSpeed` | `number` | `1.5` | Speed at which the effect fades in/out when entering/leaving the canvas. | | `fallback` | `ReactNode` | `null` | Optional fallback UI rendered via Suspense while media loads. | ## 3. Core Component Source **File Path:** `registry/ui/proximity-grid.tsx` ```tsx "use client" import React, { useMemo, useRef, Suspense } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { Html, useTexture, useVideoTexture } from "@react-three/drei" import * as THREE from "three" import { cn } from "@/lib/utils" export interface ProximityGridProps { mediaUrl: string mediaType?: "image" | "video" columns?: number rows?: number hoverRadius?: number imageZoom?: number cellRadius?: number mouseLerpSpeed?: number enterLeaveSpeed?: number className?: string fallback?: React.ReactNode } const vertexShader = ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` const fragmentShader = ` uniform sampler2D u_image; uniform vec2 u_gridSize; uniform vec2 u_mouse; uniform float u_hoverRadius; uniform vec2 u_resolution; uniform vec2 u_imageRes; uniform float u_active; uniform float u_imageZoom; uniform float u_cellRadius; varying vec2 vUv; float roundedBoxSDF(vec2 CenterPosition, vec2 Size, float Radius) { return length(max(abs(CenterPosition) - Size + Radius, 0.0)) - Radius; } void main() { vec2 cellId = floor(vUv * u_gridSize); vec2 localUv = fract(vUv * u_gridSize); vec2 cellCenter = cellId + 0.5; vec2 mouseGrid = u_mouse * u_gridSize; float dist = distance(cellCenter, mouseGrid); float rawInfluence = smoothstep(u_hoverRadius, 0.0, dist); float influence = pow(rawInfluence, 1.5) * u_active; float uvScale = 1.0 / u_imageZoom; float scale = mix(1.0, uvScale, influence); vec2 centeredLocalUv = localUv - 0.5; vec2 scaledLocalUv = centeredLocalUv * scale + 0.5; vec2 globalUv = (cellId + scaledLocalUv) / u_gridSize; vec2 ratio = u_resolution / u_imageRes; float coverRatio = max(ratio.x, ratio.y); vec2 renderSize = u_imageRes * coverRatio; vec2 offset = (u_resolution - renderSize) * 0.5; vec2 coverUv = (globalUv * u_resolution - offset) / renderSize; vec4 texColor = texture2D(u_image, coverUv); vec2 cellSize = u_resolution / u_gridSize; vec2 pos = centeredLocalUv * cellSize; vec2 boxSize = cellSize * 0.5; float maxRadius = min(boxSize.x, boxSize.y) * u_cellRadius; float radius = mix(0.0, maxRadius, influence); float d = roundedBoxSDF(pos, boxSize + vec2(0.5), radius); float mask = 1.0 - smoothstep(0.0, 1.0, d); gl_FragColor = vec4(texColor.rgb, texColor.a * mask); } ` interface GridRendererProps extends Omit { texture: THREE.Texture } const GridRenderer = ({ texture, columns = 18, rows = 12, hoverRadius = 4.0, imageZoom = 1.25, cellRadius = 0.45, mouseLerpSpeed = 2.5, enterLeaveSpeed = 1.5, }: GridRendererProps) => { const materialRef = useRef(null) const { size, viewport } = useThree() const targetMouse = useRef(new THREE.Vector2(0.5, 0.5)) const activeState = useRef(0) // Initialize uniforms once. Reactive properties removed to prevent scroll-resizing resets. const uniforms = useMemo(() => { const img = texture.image as HTMLImageElement | HTMLVideoElement | null let width = 1 let height = 1 if (img) { if ("videoWidth" in img) { width = img.videoWidth height = img.videoHeight } else { width = img.naturalWidth || img.width height = img.naturalHeight || img.height } } return { u_image: { value: texture }, u_gridSize: { value: new THREE.Vector2(columns, rows) }, u_mouse: { value: new THREE.Vector2(0.5, 0.5) }, u_hoverRadius: { value: hoverRadius }, u_resolution: { value: new THREE.Vector2(1, 1) }, u_imageRes: { value: new THREE.Vector2(width, height) }, u_active: { value: 0.0 }, u_imageZoom: { value: imageZoom }, u_cellRadius: { value: cellRadius }, } }, [texture]) useFrame((state, delta) => { if (!materialRef.current) return // Clamp delta to prevent extreme physics overshooting on lag spikes const dt = Math.min(delta, 0.1) const mx = state.pointer.x * 0.5 + 0.5 const my = state.pointer.y * 0.5 + 0.5 targetMouse.current.set(mx, my) materialRef.current.uniforms.u_mouse.value.lerp( targetMouse.current, Math.min(dt * mouseLerpSpeed, 1.0) ) materialRef.current.uniforms.u_active.value = THREE.MathUtils.lerp( materialRef.current.uniforms.u_active.value, activeState.current, Math.min(dt * enterLeaveSpeed, 1.0) ) // Manually push reactive properties into uniforms materialRef.current.uniforms.u_resolution.value.set(size.width, size.height) materialRef.current.uniforms.u_gridSize.value.set(columns, rows) materialRef.current.uniforms.u_hoverRadius.value = hoverRadius materialRef.current.uniforms.u_imageZoom.value = imageZoom materialRef.current.uniforms.u_cellRadius.value = cellRadius }) return ( (activeState.current = 1)} onPointerLeave={() => (activeState.current = 0)} onPointerCancel={() => (activeState.current = 0)} onPointerOut={() => (activeState.current = 0)} > ) } const ImageScene = ({ mediaUrl, ...props }: { mediaUrl: string } & Partial) => { const texture = useTexture(mediaUrl) return } const VideoScene = ({ mediaUrl, ...props }: { mediaUrl: string } & Partial) => { const texture = useVideoTexture(mediaUrl, { crossOrigin: "Anonymous", muted: true, loop: true, start: true, }) return } export default function ProximityGrid({ mediaUrl, mediaType = "image", columns = 18, rows = 12, hoverRadius = 4.0, imageZoom = 1.25, cellRadius = 0.45, mouseLerpSpeed = 2.5, enterLeaveSpeed = 1.5, className, fallback, }: ProximityGridProps) { return (
{fallback} : null}> {mediaType === "video" ? ( ) : ( )}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import ProximityGrid from "@/registry/ui/proximity-grid" export default function ExamplePage() { return (
Loading image...
} />
) } ``` --- # Scatter Trail Component Context **Description:** A physics-based mouse trail that calculates pointer velocity to dynamically throw, slide, and spin images across the screen like dealing casino cards. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/scatter-trail.json ``` **Dependencies installed:** `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :---------------- | :--------- | :------ | :--------------------------------------------------------------------------- | | `imageUrls` | `string[]` | `[]` | Array of image URLs to randomly spawn. | | `distance` | `number` | `40` | Distance the pointer must move (in pixels) before spawning a new item. | | `duration` | `number` | `1500` | The total lifespan of a spawned item in milliseconds. | | `maxItems` | `number` | `20` | Maximum items on screen before forcing the oldest to fade out. | | `itemSize` | `number` | `100` | The base pixel size of the image cards. | | `slideMultiplier` | `number` | `120` | Multiplier for the throw distance based on mouse velocity. Higher = further. | | `maxSlide` | `number` | `400` | Absolute maximum pixels a card can slide to prevent flying off screen. | | `scatterSpread` | `number` | `0.7` | How wide the angle of randomization is when a card is thrown (in radians). | | `className` | `string` | `""` | Optional standard Tailwind classes for the wrapper. | | `itemClassName` | `string` | `""` | Optional standard Tailwind classes for the individual image cards. | ## 3. Core Component Source **File Path:** `registry/ui/scatter-trail.tsx` ```tsx "use client" import { cn } from "@/lib/utils" import { useEffect, useRef } from "react" export interface ScatterTrailProps { imageUrls: string[] distance?: number duration?: number maxItems?: number itemSize?: number slideMultiplier?: number maxSlide?: number scatterSpread?: number className?: string itemClassName?: string } interface TileData { active: boolean startX: number startY: number targetX: number targetY: number startRotation: number targetRotation: number imageIndex: number zIndex: number t: number state: "enter" | "hold" | "exit" holdTime: number spawnTime: number } export default function ScatterTrail({ imageUrls, distance = 40, duration = 1500, maxItems = 20, itemSize = 100, slideMultiplier = 120, maxSlide = 400, scatterSpread = 0.7, className, itemClassName = "", }: ScatterTrailProps) { const reqRef = useRef(null) const DOM_POOL_SIZE = maxItems * 3 const pool = useRef( Array.from({ length: DOM_POOL_SIZE }, () => ({ active: false, startX: 0, startY: 0, targetX: 0, targetY: 0, startRotation: 0, targetRotation: 0, imageIndex: 0, zIndex: 0, t: 0, state: "enter", holdTime: 0, spawnTime: 0, })) ) const domRefs = useRef<(HTMLDivElement | null)[]>([]) const state = useRef({ lastDropPos: { x: -1000, y: -1000 }, lastMovePos: { x: -1000, y: -1000 }, lastMoveTime: 0, spawnCount: 0, lastFrameTime: 0, }) const config = useRef({ imageUrls, distance, maxItems, duration, itemSize, slideMultiplier, maxSlide, scatterSpread }) useEffect(() => { config.current = { imageUrls, distance, maxItems, duration, itemSize, slideMultiplier, maxSlide, scatterSpread } }, [imageUrls, distance, maxItems, duration, itemSize, slideMultiplier, maxSlide, scatterSpread]) useEffect(() => { const handlePointerMove = (e: PointerEvent) => { const s = state.current const c = config.current const now = Date.now() if (s.lastMovePos.x === -1000) { s.lastMovePos = { x: e.clientX, y: e.clientY } s.lastMoveTime = now return } const moveDx = e.clientX - s.lastMovePos.x const moveDy = e.clientY - s.lastMovePos.y const dt = Math.max(now - s.lastMoveTime, 1) const vx = moveDx / dt const vy = moveDy / dt s.lastMovePos = { x: e.clientX, y: e.clientY } s.lastMoveTime = now const dropDx = e.clientX - s.lastDropPos.x const dropDy = e.clientY - s.lastDropPos.y const dropDistance = Math.hypot(dropDx, dropDy) if (dropDistance >= c.distance) { const activeNonExiting = pool.current.filter( (p) => p.active && p.state !== "exit" ) if (activeNonExiting.length >= c.maxItems) { activeNonExiting.sort((a, b) => a.spawnTime - b.spawnTime) const oldest = activeNonExiting[0] oldest.state = "exit" } const freeIndex = pool.current.findIndex((p) => !p.active) if (freeIndex !== -1) { s.lastDropPos = { x: e.clientX, y: e.clientY } s.spawnCount += 1 const speed = Math.hypot(vx, vy) const slideDistance = Math.min(speed * c.slideMultiplier, c.maxSlide) const baseAngle = Math.atan2(vy, vx) const spreadAngle = baseAngle + (Math.random() - 0.5) * c.scatterSpread const targetX = e.clientX + Math.cos(spreadAngle) * slideDistance const targetY = e.clientY + Math.sin(spreadAngle) * slideDistance const startRotation = (Math.random() - 0.5) * 90 const spinDirection = Math.random() > 0.5 ? 1 : -1 const spinAmount = slideDistance * 0.8 * spinDirection pool.current[freeIndex] = { active: true, startX: e.clientX, startY: e.clientY, targetX, targetY, startRotation, targetRotation: startRotation + spinAmount, imageIndex: s.spawnCount % c.imageUrls.length, zIndex: s.spawnCount, t: 0, state: "enter", holdTime: 0, spawnTime: Date.now(), } } } } window.addEventListener("pointermove", handlePointerMove) return () => window.removeEventListener("pointermove", handlePointerMove) }, []) useEffect(() => { state.current.lastFrameTime = Date.now() const animate = () => { const c = config.current const currentTime = Date.now() const delta = Math.min(currentTime - state.current.lastFrameTime, 32) state.current.lastFrameTime = currentTime const enterDuration = c.duration * 0.35 const holdDuration = c.duration * 0.45 const exitDuration = c.duration * 0.2 for (let i = 0; i < DOM_POOL_SIZE; i++) { const item = pool.current[i] const domNode = domRefs.current[i] if (!domNode) continue if (!item.active) { domNode.style.display = "none" continue } if (item.state === "enter") { item.t += delta / enterDuration if (item.t >= 1) { item.t = 1 item.state = "hold" } } else if (item.state === "hold") { item.holdTime += delta if (item.holdTime >= holdDuration) { item.state = "exit" } } else if (item.state === "exit") { item.t -= delta / exitDuration if (item.t <= 0) { item.t = 0 item.active = false domNode.style.display = "none" continue } } let currentX = item.startX let currentY = item.startY let currentRotation = item.startRotation let scale = 1 let opacity = 1 if (item.state === "enter") { const easeOutFriction = 1 - Math.pow(1 - item.t, 4) currentX = item.startX + (item.targetX - item.startX) * easeOutFriction currentY = item.startY + (item.targetY - item.startY) * easeOutFriction currentRotation = item.startRotation + (item.targetRotation - item.startRotation) * easeOutFriction scale = 0.9 + easeOutFriction * 0.1 } else if (item.state === "hold") { currentX = item.targetX currentY = item.targetY currentRotation = item.targetRotation scale = 1 } else if (item.state === "exit") { currentX = item.targetX currentY = item.targetY currentRotation = item.targetRotation const easeIn = item.t * item.t scale = easeIn opacity = item.t } domNode.style.display = "flex" domNode.style.zIndex = item.zIndex.toString() domNode.style.opacity = opacity.toString() domNode.style.transform = ` translate3d(${currentX}px, ${currentY}px, 0) rotate(${currentRotation}deg) scale(${scale}) ` const imagesInside = domNode.querySelectorAll("img") imagesInside.forEach((img, idx) => { img.style.display = idx === item.imageIndex ? "block" : "none" }) } reqRef.current = requestAnimationFrame(animate) } reqRef.current = requestAnimationFrame(animate) return () => { if (reqRef.current) cancelAnimationFrame(reqRef.current) } }, [DOM_POOL_SIZE]) useEffect(() => { imageUrls.forEach((src) => { const img = new Image() img.crossOrigin = "anonymous" img.referrerPolicy = "no-referrer" img.src = src }) }, [imageUrls]) return (
{Array.from({ length: DOM_POOL_SIZE }).map((_, i) => (
{ domRefs.current[i] = el }} className={cn( "absolute top-0 left-0 overflow-hidden bg-transparent p-0 drop-shadow-2xl will-change-transform", itemClassName )} style={{ width: `${itemSize}px`, height: `${itemSize}px`, marginLeft: `-${itemSize / 2}px`, marginTop: `-${itemSize / 2}px`, borderRadius: "15%", display: "none", willChange: "transform, opacity", }} > {imageUrls.map((src, imgIndex) => ( trail ))}
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import ScatterTrail from "@/registry/ui/scatter-trail" const trailImages = [ "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/14.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/17.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/18.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/19.jpg", ] export default function ExamplePage() { return (

Scatter Cards.

) } ``` --- # Slinky Trail Component Context **Description:** An interconnected, continuous spring-physics mouse trail that simulates a slinky or snake chasing the pointer. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/slinky-trail.json ``` **Dependencies installed:** `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :-------------- | :--------- | :------ | :----------------------------------------------------------------------------------- | | `imageUrls` | `string[]` | `[]` | Array of image URLs to render. | | `maxItems` | `number` | `12` | Total number of cards in the continuous chain. | | `itemSize` | `number` | `110` | The base pixel size of the image cards. | | `stiffness` | `number` | `0.3` | How tight the spring is. Lower = looser/longer snake, Higher = tighter/shorter snake | | `className` | `string` | `""` | Optional standard Tailwind classes for the wrapper. | | `itemClassName` | `string` | `""` | Optional standard Tailwind classes for the individual image cards. | ## 3. Core Component Source **File Path:** `registry/ui/slinky-trail.tsx` ```tsx "use client" import { cn } from "@/lib/utils" import { useEffect, useRef } from "react" export interface SlinkyTrailProps { imageUrls?: string[] maxItems?: number itemSize?: number stiffness?: number className?: string itemClassName?: string } interface NodeData { x: number y: number vx: number vy: number rx: number baseRot: number imageIndex: number } export default function SlinkyTrail({ imageUrls = [], maxItems = 12, itemSize = 110, stiffness = 0.3, className, itemClassName = "", }: SlinkyTrailProps) { const reqRef = useRef(null) const nodes = useRef( Array.from({ length: maxItems }, (_, i) => ({ x: -1000, y: -1000, vx: 0, vy: 0, rx: 0, baseRot: i % 2 === 0 ? i * 2.5 : -i * 2.5, imageIndex: i, })) ) const domRefs = useRef<(HTMLDivElement | null)[]>([]) const mouse = useRef({ x: -1000, y: -1000, moved: false, }) const config = useRef({ imageUrls, maxItems, itemSize, stiffness }) useEffect(() => { config.current = { imageUrls, maxItems, itemSize, stiffness } }, [imageUrls, maxItems, itemSize, stiffness]) useEffect(() => { const handlePointerMove = (e: PointerEvent) => { const c = config.current if (c.imageUrls.length === 0) return mouse.current.x = e.clientX mouse.current.y = e.clientY if (!mouse.current.moved) { mouse.current.moved = true for (let i = 0; i < c.maxItems; i++) { if (nodes.current[i]) { nodes.current[i].x = e.clientX nodes.current[i].y = e.clientY } } } } window.addEventListener("pointermove", handlePointerMove) return () => window.removeEventListener("pointermove", handlePointerMove) }, []) useEffect(() => { const animate = () => { const c = config.current if (mouse.current.moved && c.imageUrls.length > 0) { for (let i = 0; i < c.maxItems; i++) { const node = nodes.current[i] const domNode = domRefs.current[i] if (!node || !domNode) continue const targetX = i === 0 ? mouse.current.x : nodes.current[i - 1].x const targetY = i === 0 ? mouse.current.y : nodes.current[i - 1].y node.vx = (targetX - node.x) * c.stiffness node.vy = (targetY - node.y) * c.stiffness node.x += node.vx node.y += node.vy const targetRot = node.vx * 1.5 node.rx += (targetRot + node.baseRot - node.rx) * 0.2 const scale = 1 - (i / c.maxItems) * 0.25 domNode.style.display = "flex" domNode.style.zIndex = (c.maxItems - i).toString() domNode.style.transform = ` translate3d(${node.x}px, ${node.y}px, 0) rotate(${node.rx}deg) scale(${scale}) ` const safeImageIndex = node.imageIndex % c.imageUrls.length const imagesInside = domNode.querySelectorAll("img.js-trail-img") imagesInside.forEach((img, idx) => { img.style.display = idx === safeImageIndex ? "block" : "none" }) } } reqRef.current = requestAnimationFrame(animate) } reqRef.current = requestAnimationFrame(animate) return () => { if (reqRef.current) cancelAnimationFrame(reqRef.current) } }, []) useEffect(() => { imageUrls.forEach((src) => { const img = new Image() img.crossOrigin = "anonymous" img.referrerPolicy = "no-referrer" img.src = src }) }, [imageUrls]) return (
{Array.from({ length: maxItems }).map((_, i) => (
{ domRefs.current[i] = el }} className={cn( "absolute top-0 left-0 origin-center overflow-hidden bg-transparent p-0 drop-shadow-2xl will-change-transform", itemClassName )} style={{ width: `${itemSize}px`, height: `${itemSize}px`, marginLeft: `-${itemSize / 2}px`, marginTop: `-${itemSize / 2}px`, borderRadius: "22%", display: "none", }} > {imageUrls.map((src, imgIndex) => ( trail ))}
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import SlinkyTrail from "@/registry/ui/slinky-trail" const trailImages = [ "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/14.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/17.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/18.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/19.jpg", ] export default function ExamplePage() { return (

Slinky.

) } ``` --- # Squircle Trail Component Context **Description:** A snappy, hardware-accelerated mouse trail component that leaves behind rounded-square images with optional path-directional rotation. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/squircle-trail.json ``` **Dependencies installed:** `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :-------------------- | :------------------ | :------ | :--------------------------------------------------------------------- | | `imageUrls` | `string[]` | `[]` | Array of image URLs to randomly spawn. | | `distance` | `number` | `30` | Distance the pointer must move (in pixels) before spawning a new item. | | `duration` | `number` | `1200` | The total lifespan of a spawned item in milliseconds. | | `maxItems` | `number` | `7` | Maximum items on screen before forcing the oldest to fade out. | | `itemSize` | `number` | `120` | The base pixel size of the image cards. | | `rotationRange` | `number` | `15` | Maximum random rotation (in degrees) applied to the cards. | | `directionalRotation` | `boolean \| number` | `false` | When enabled, objects rotate to point along the mouse path curve. | | `className` | `string` | `""` | Optional standard Tailwind classes for the wrapper. | | `itemClassName` | `string` | `""` | Optional standard Tailwind classes for the individual image cards. | ## 3. Core Component Source **File Path:** `registry/ui/squircle-trail.tsx` ```tsx "use client" import { cn } from "@/lib/utils" import { useEffect, useRef } from "react" export interface SquircleTrailProps { imageUrls?: string[] distance?: number duration?: number maxItems?: number itemSize?: number rotationRange?: number directionalRotation?: boolean | number className?: string itemClassName?: string } interface TileData { active: boolean x: number y: number rotation: number imageIndex: number zIndex: number t: number state: "enter" | "hold" | "exit" holdTime: number spawnTime: number } export default function SquircleTrail({ imageUrls = [], distance = 30, duration = 1200, maxItems = 7, itemSize = 120, rotationRange = 15, directionalRotation = false, className, itemClassName = "", }: SquircleTrailProps) { const reqRef = useRef(null) const DOM_POOL_SIZE = maxItems * 3 const pool = useRef( Array.from({ length: DOM_POOL_SIZE }, () => ({ active: false, x: 0, y: 0, rotation: 0, imageIndex: 0, zIndex: 0, t: 0, state: "enter", holdTime: 0, spawnTime: 0, })) ) const domRefs = useRef<(HTMLDivElement | null)[]>([]) const state = useRef({ lastDropPos: { x: -1000, y: -1000 }, spawnCount: 0, lastFrameTime: 0, }) const config = useRef({ imageUrls, distance, maxItems, duration, itemSize, rotationRange, directionalRotation }) useEffect(() => { config.current = { imageUrls, distance, maxItems, duration, itemSize, rotationRange, directionalRotation } }, [imageUrls, distance, maxItems, duration, itemSize, rotationRange, directionalRotation]) useEffect(() => { const handlePointerMove = (e: PointerEvent) => { const s = state.current const c = config.current if (c.imageUrls.length === 0) return const dy = e.clientY - s.lastDropPos.y const dx = e.clientX - s.lastDropPos.x const moveDist = Math.hypot(dx, dy) if (moveDist >= c.distance) { const activeNonExiting = pool.current.filter( (p) => p.active && p.state !== "exit" ) if (activeNonExiting.length >= c.maxItems) { activeNonExiting.sort((a, b) => a.spawnTime - b.spawnTime) const oldest = activeNonExiting[0] oldest.state = "exit" } const freeIndex = pool.current.findIndex((p) => !p.active) if (freeIndex !== -1) { const isFirstDrop = s.lastDropPos.x === -1000 s.lastDropPos = { x: e.clientX, y: e.clientY } s.spawnCount += 1 const pathAngle = Math.atan2(dy, dx) * (180 / Math.PI) const offset = typeof c.directionalRotation === "number" ? c.directionalRotation : 90 pool.current[freeIndex] = { active: true, x: e.clientX, y: e.clientY, rotation: c.directionalRotation !== false ? isFirstDrop ? 0 : pathAngle + offset : (Math.random() - 0.5) * (c.rotationRange * 2), imageIndex: s.spawnCount % c.imageUrls.length, zIndex: s.spawnCount, t: 0, state: "enter", holdTime: 0, spawnTime: Date.now(), } } } } window.addEventListener("pointermove", handlePointerMove) return () => window.removeEventListener("pointermove", handlePointerMove) }, []) useEffect(() => { state.current.lastFrameTime = Date.now() const animate = () => { const c = config.current const currentTime = Date.now() const delta = Math.min(currentTime - state.current.lastFrameTime, 32) state.current.lastFrameTime = currentTime const enterDuration = c.duration * 0.2 const holdDuration = c.duration * 0.6 const exitDuration = c.duration * 0.2 for (let i = 0; i < DOM_POOL_SIZE; i++) { const item = pool.current[i] const domNode = domRefs.current[i] if (!domNode) continue if (!item.active) { domNode.style.display = "none" continue } if (item.state === "enter") { item.t += delta / enterDuration if (item.t >= 1) { item.t = 1 item.state = "hold" } } else if (item.state === "hold") { item.holdTime += delta if (item.holdTime >= holdDuration) { item.state = "exit" } } else if (item.state === "exit") { item.t -= delta / exitDuration if (item.t <= 0) { item.t = 0 item.active = false domNode.style.display = "none" continue } } const scale = Math.max(0, item.t) domNode.style.display = "flex" domNode.style.zIndex = item.zIndex.toString() domNode.style.transform = ` translate3d(${item.x}px, ${item.y}px, 0) rotate(${item.rotation}deg) scale(${scale}) ` const imagesInside = domNode.querySelectorAll("img") imagesInside.forEach((img, idx) => { img.style.display = idx === item.imageIndex ? "block" : "none" }) } reqRef.current = requestAnimationFrame(animate) } reqRef.current = requestAnimationFrame(animate) return () => { if (reqRef.current) cancelAnimationFrame(reqRef.current) } }, [DOM_POOL_SIZE]) useEffect(() => { imageUrls.forEach((src) => { const img = new Image() img.crossOrigin = "anonymous" img.referrerPolicy = "no-referrer" img.src = src }) }, [imageUrls]) return (
{Array.from({ length: DOM_POOL_SIZE }).map((_, i) => (
{ domRefs.current[i] = el }} className={cn( "absolute top-0 left-0 overflow-hidden bg-transparent p-0 drop-shadow-2xl will-change-transform", itemClassName )} style={{ width: `${itemSize}px`, height: `${itemSize}px`, marginLeft: `-${itemSize / 2}px`, marginTop: `-${itemSize / 2}px`, borderRadius: "22%", display: "none", }} > {imageUrls.map((src, imgIndex) => ( trail ))}
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import SquircleTrail from "@/registry/ui/squircle-trail" const trailImages = [ "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/14.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/17.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/18.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/19.jpg", ] export default function ExamplePage() { return (

Squircles.

) } ``` --- # Tumbler Roll Reveal Component Context **Description:** A mechanical 3D text reveal component for Satis UI. Characters or words roll into place along an invisible 3D cylinder using Z-axis transform-origin math, mimicking the tactile snap of a combination lock or vintage split-flap display. Features full vestibular disorder failsafes to override the 3D transformations for accessible fading. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/tumbler-roll-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :--------------- | :------------------ | :---------- | :---------------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `splitBy` | `"word" \| "char"` | `"char"` | Split mode. | | `startAngle` | `number` | `110` | Starting X-axis rotation. | | `cylinderRadius` | `string` | `"-0.8em"` | Depth of the invisible 3D cylinder. | | `duration` | `number` | `0.9` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.04` | Stagger timing between items. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 90%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/tumbler-roll-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface TumblerRollRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" startAngle?: number cylinderRadius?: string duration?: number delay?: number stagger?: number viewportOnce?: boolean triggerStart?: string } export const TumblerRollReveal = React.forwardRef< HTMLElement, TumblerRollRevealProps >( ( { text, as = "h1", className, splitBy = "char", startAngle = 110, cylinderRadius = "-0.8em", duration = 0.9, delay = 0, stagger = 0.04, viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".tumbler-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { rotateX: startAngle, opacity: 0, }, { rotateX: 0, opacity: 1, duration, delay, stagger, ease: "back.out(1.2)", force3D: true, clearProps: "transform,opacity", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0, rotateX: 0 }, { opacity: 1, rotateX: 0, duration: 0.5, delay, stagger, ease: "none", clearProps: "transform,opacity", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ duration, delay, stagger, startAngle, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: `rotateX(${startAngle}deg)`, transformOrigin: `50% 50% ${cylinderRadius}`, transformStyle: "preserve-3d", willChange: "transform, opacity", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) TumblerRollReveal.displayName = "TumblerRollReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { TumblerRollReveal } from "@/registry/ui/tumbler-roll-reveal" export default function ExamplePage() { return (
) } ``` --- # Velocity Brake Reveal Component Context **Description:** A kinetic text reveal component for Satis UI. Elements slide in rapidly from an offset and slam on the brakes, whipping forward into a heavy skew overshoot before settling. Includes complete GSAP `clearProps` cleanup for pristine font rendering, ARIA screen-reader support, and vestibular motion failsafes. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/velocity-brake-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :---------- | :-------------------------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `splitBy` | `"word" \| "char"` | `"char"` | Split mode. | | `startX` | `string \| number` | `"-3em"` | Starting X offset (e.g., `-3em` or `-100px`). | | `startSkew` | `number` | `-25` | Starting skew angle (drag simulation). | | `duration` | `number` | `0.9` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.04` | Stagger timing between items. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 90%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/velocity-brake-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface VelocityBrakeRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" startX?: string | number startSkew?: number duration?: number delay?: number stagger?: number viewportOnce?: boolean triggerStart?: string } export const VelocityBrakeReveal = React.forwardRef< HTMLElement, VelocityBrakeRevealProps >( ( { text, as = "h1", className, splitBy = "char", startX = "-3em", startSkew = -25, duration = 0.9, delay = 0, stagger = 0.04, viewportOnce = true, triggerStart = "top 90%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".brake-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { x: startX, skewX: startSkew, opacity: 0, }, { x: 0, skewX: 0, opacity: 1, duration, delay, stagger, ease: "back.out(2.5)", force3D: true, clearProps: "transform,opacity", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", clearProps: "transform", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ text, startX, startSkew, duration, delay, stagger, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: `translateX(${startX}) skewX(${startSkew}deg)`, transformOrigin: "bottom center", willChange: "transform, opacity", display: "inline-block", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) VelocityBrakeReveal.displayName = "VelocityBrakeReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { VelocityBrakeReveal } from "@/registry/ui/velocity-brake-reveal" export default function ExamplePage() { return (
) } ``` --- # Velocity Grid Component Context **Description:** An interactive WebGL component that displays media (image or video) with a fluid, 2D grid-based velocity distortion effect following the cursor. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/velocity-grid.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :---------------- | :------------------- | :-------- | :------------------------------------------------------------ | | `mediaUrl` | `string` | — | The source URL of the media asset. | | `mediaType` | `"image" \| "video"` | `"image"` | Defines the media type for correct texture processing. | | `columns` | `number` | `24` | Number of vertical grid columns. | | `rows` | `number` | `16` | Number of horizontal grid rows. | | `hoverRadius` | `number` | `0.35` | Proximity spread multiplier (0.0 to 1.0). | | `shiftMultiplier` | `number` | `1.5` | Strength of the displacement shift based on mouse velocity. | | `trackingSpeed` | `number` | `2.0` | Interpolation speed for the wave following the cursor. | | `imageZoom` | `number` | `1.15` | Base texture scale to prevent edge bleeding. | | `enterLeaveSpeed` | `number` | `1.5` | Interpolation speed for the enter/leave animation states. | | `fallback` | `ReactNode` | `null` | Optional fallback UI rendered via Suspense while media loads. | ## 3. Core Component Source **File Path:** `registry/ui/velocity-grid.tsx` ```tsx "use client" import React, { useMemo, useRef, Suspense } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { Html, useTexture, useVideoTexture } from "@react-three/drei" import * as THREE from "three" import { cn } from "@/lib/utils" export interface VelocityGridProps { mediaUrl: string mediaType?: "image" | "video" columns?: number rows?: number hoverRadius?: number shiftMultiplier?: number trackingSpeed?: number imageZoom?: number enterLeaveSpeed?: number className?: string fallback?: React.ReactNode } const vertexShader = ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` const fragmentShader = ` uniform sampler2D u_image; uniform vec2 u_gridSize; uniform vec2 u_mouse; uniform vec2 u_velocity; uniform float u_hoverRadius; uniform float u_imageZoom; uniform vec2 u_resolution; uniform vec2 u_imageRes; uniform float u_active; varying vec2 vUv; void main() { // 1. 2D Grid Logic vec2 cellId = floor(vUv * u_gridSize); vec2 cellCenter = (cellId + 0.5) / u_gridSize; // 2. Aspect-Corrected Proximity vec2 aspect = vec2(u_resolution.x / u_resolution.y, 1.0); float dist = distance(cellCenter * aspect, u_mouse * aspect); float rawInfluence = smoothstep(u_hoverRadius, 0.0, dist); float influence = pow(rawInfluence, 1.6) * u_active; // 3. Zoom Math vec2 zoomedUv = (vUv - 0.5) * (1.0 / u_imageZoom) + 0.5; // 4. 2D Velocity Shift vec2 shiftedUv = zoomedUv; shiftedUv -= influence * u_velocity; // 5. Object-Cover Math (Responsive aspect ratio lock) vec2 ratio = u_resolution / u_imageRes; float coverRatio = max(ratio.x, ratio.y); vec2 renderSize = u_imageRes * coverRatio; vec2 offset = (u_resolution - renderSize) * 0.5; vec2 coverUv = (shiftedUv * u_resolution - offset) / renderSize; gl_FragColor = texture2D(u_image, coverUv); } ` interface GridRendererProps extends Omit { texture: THREE.Texture } const GridRenderer = ({ texture, columns = 24, rows = 16, hoverRadius = 0.35, shiftMultiplier = 1.5, trackingSpeed = 2.0, imageZoom = 1.15, enterLeaveSpeed = 1.5, }: GridRendererProps) => { const materialRef = useRef(null) const { size, viewport } = useThree() const targetMouse = useRef(new THREE.Vector2(0.5, 0.5)) const smoothMouse = useRef(new THREE.Vector2(0.5, 0.5)) const targetVelocity = useRef(new THREE.Vector2(0, 0)) const smoothVelocity = useRef(new THREE.Vector2(0, 0)) const activeState = useRef(0) // Initialize uniforms once. Reactive properties removed to prevent scroll-resizing resets. const uniforms = useMemo(() => { const img = texture.image as HTMLImageElement | HTMLVideoElement | null let width = 1, height = 1 if (img) { if ("videoWidth" in img) { width = img.videoWidth height = img.videoHeight } else { width = img.naturalWidth || img.width height = img.naturalHeight || img.height } } return { u_image: { value: texture }, u_gridSize: { value: new THREE.Vector2(columns, rows) }, u_mouse: { value: new THREE.Vector2(0.5, 0.5) }, u_velocity: { value: new THREE.Vector2(0.0, 0.0) }, u_hoverRadius: { value: hoverRadius }, u_imageZoom: { value: imageZoom }, u_resolution: { value: new THREE.Vector2(1, 1) }, u_imageRes: { value: new THREE.Vector2(width, height) }, u_active: { value: 0.0 }, } }, [texture]) useFrame((state, delta) => { if (!materialRef.current) return // Clamp delta to 100ms to prevent extreme physics overshooting on lag spikes const dt = Math.min(delta, 0.1) targetMouse.current.set(state.pointer.x * 0.5 + 0.5, state.pointer.y * 0.5 + 0.5) smoothMouse.current.lerp(targetMouse.current, Math.min(dt * trackingSpeed, 1.0)) targetVelocity.current.subVectors(targetMouse.current, smoothMouse.current).multiplyScalar(shiftMultiplier) // Clamp both X and Y target velocities to prevent diagonal edge bleeding targetVelocity.current.x = THREE.MathUtils.clamp(targetVelocity.current.x, -0.4, 0.4) targetVelocity.current.y = THREE.MathUtils.clamp(targetVelocity.current.y, -0.4, 0.4) smoothVelocity.current.lerp(targetVelocity.current, Math.min(dt * 4.0, 1.0)) materialRef.current.uniforms.u_mouse.value.copy(smoothMouse.current) materialRef.current.uniforms.u_velocity.value.copy(smoothVelocity.current) materialRef.current.uniforms.u_active.value = THREE.MathUtils.lerp( materialRef.current.uniforms.u_active.value, activeState.current, Math.min(dt * enterLeaveSpeed, 1.0) ) // Manually push reactive properties into uniforms to bypass useMemo recreation materialRef.current.uniforms.u_resolution.value.set(size.width, size.height) materialRef.current.uniforms.u_gridSize.value.set(columns, rows) materialRef.current.uniforms.u_hoverRadius.value = hoverRadius materialRef.current.uniforms.u_imageZoom.value = imageZoom }) return ( (activeState.current = 1)} onPointerLeave={() => (activeState.current = 0)} onPointerCancel={() => (activeState.current = 0)} onPointerOut={() => (activeState.current = 0)} > ) } const ImageScene = ({ mediaUrl, ...props }: { mediaUrl: string } & Partial) => { const texture = useTexture(mediaUrl) return } const VideoScene = ({ mediaUrl, ...props }: { mediaUrl: string } & Partial) => { const texture = useVideoTexture(mediaUrl, { crossOrigin: "Anonymous", muted: true, loop: true, start: true }) return } export default function VelocityGrid({ mediaUrl, mediaType = "image", columns = 24, rows = 16, hoverRadius = 0.35, shiftMultiplier = 1.5, trackingSpeed = 2.0, imageZoom = 1.15, enterLeaveSpeed = 1.5, className, fallback, }: VelocityGridProps) { return (
{fallback} : null}> {mediaType === "video" ? ( ) : ( )}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import VelocityGrid from "@/registry/ui/velocity-grid" export default function ExamplePage() { return (
Loading image...
} />
) } ``` --- # Velocity Strips Component Context **Description:** An interactive WebGL component that displays media (image or video) with a fluid, velocity-based slice distortion effect following the cursor. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/velocity-strips.json ``` **Dependencies installed:** `three`, `@react-three/fiber`, `@react-three/drei`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :---------------- | :------------------- | :-------- | :------------------------------------------------------------ | | `mediaUrl` | `string` | — | The source URL of the media asset. | | `mediaType` | `"image" \| "video"` | `"image"` | Defines the media type for correct texture processing. | | `slices` | `number` | `32` | Number of vertical displacement slices. | | `hoverRadius` | `number` | `0.35` | Proximity spread multiplier (0.0 to 1.0). | | `shiftMultiplier` | `number` | `1.5` | Strength of the displacement shift based on mouse velocity. | | `trackingSpeed` | `number` | `2.0` | Interpolation speed for the wave following the cursor. | | `imageZoom` | `number` | `1.15` | Base texture scale to prevent edge bleeding. | | `enterLeaveSpeed` | `number` | `1.5` | Interpolation speed for the enter/leave animation states. | | `fallback` | `ReactNode` | `null` | Optional fallback UI rendered via Suspense while media loads. | ## 3. Core Component Source **File Path:** `registry/ui/velocity-strips.tsx` ```tsx "use client" import React, { useMemo, useRef, Suspense } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" import { Html, useTexture, useVideoTexture } from "@react-three/drei" import * as THREE from "three" import { cn } from "@/lib/utils" export interface VelocityStripsProps { mediaUrl: string mediaType?: "image" | "video" slices?: number hoverRadius?: number shiftMultiplier?: number trackingSpeed?: number imageZoom?: number enterLeaveSpeed?: number className?: string fallback?: React.ReactNode } const vertexShader = ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` const fragmentShader = ` uniform sampler2D u_image; uniform float u_slices; uniform vec2 u_mouse; uniform float u_hoverRadius; uniform float u_velocity; uniform float u_imageZoom; uniform vec2 u_resolution; uniform vec2 u_imageRes; uniform float u_active; varying vec2 vUv; void main() { float sliceId = floor(vUv.x * u_slices); float sliceCenter = (sliceId + 0.5) / u_slices; float dist = abs(sliceCenter - u_mouse.x); float rawInfluence = smoothstep(u_hoverRadius, 0.0, dist); float influence = pow(rawInfluence, 1.6) * u_active; vec2 zoomedUv = (vUv - 0.5) * (1.0 / u_imageZoom) + 0.5; vec2 shiftedUv = zoomedUv; shiftedUv.x -= influence * u_velocity; vec2 ratio = u_resolution / u_imageRes; float coverRatio = max(ratio.x, ratio.y); vec2 renderSize = u_imageRes * coverRatio; vec2 offset = (u_resolution - renderSize) * 0.5; vec2 coverUv = (shiftedUv * u_resolution - offset) / renderSize; gl_FragColor = texture2D(u_image, coverUv); } ` interface StripsRendererProps extends Omit { texture: THREE.Texture } const StripsRenderer = ({ texture, slices = 32, hoverRadius = 0.35, shiftMultiplier = 1.5, trackingSpeed = 2.0, imageZoom = 1.15, enterLeaveSpeed = 1.5 }: StripsRendererProps) => { const materialRef = useRef(null) const { size, viewport } = useThree() const targetMouse = useRef(new THREE.Vector2(0.5, 0.5)) const smoothMouse = useRef(new THREE.Vector2(0.5, 0.5)) const targetVelocity = useRef(0) const smoothVelocity = useRef(0) const activeState = useRef(0) // Initialize uniforms once. Reactive dependencies removed to prevent scroll-resizing resets. const uniforms = useMemo(() => { const img = texture.image as HTMLImageElement | HTMLVideoElement | null let width = 1, height = 1 if (img) { if ("videoWidth" in img) { width = img.videoWidth; height = img.videoHeight } else { width = img.naturalWidth || img.width; height = img.naturalHeight || img.height } } return { u_image: { value: texture }, u_slices: { value: slices }, u_mouse: { value: new THREE.Vector2(0.5, 0.5) }, u_hoverRadius: { value: hoverRadius }, u_velocity: { value: 0.0 }, u_imageZoom: { value: imageZoom }, u_resolution: { value: new THREE.Vector2(1, 1) }, u_imageRes: { value: new THREE.Vector2(width, height) }, u_active: { value: 0.0 }, } }, [texture]) useFrame((state, delta) => { if (!materialRef.current) return // Cap delta to prevent lag spikes from causing mathematical overshoots const dt = Math.min(delta, 0.1) targetMouse.current.set(state.pointer.x * 0.5 + 0.5, state.pointer.y * 0.5 + 0.5) smoothMouse.current.lerp(targetMouse.current, Math.min(dt * trackingSpeed, 1.0)) // Calculate and clamp velocity to prevent texture edge bleeding const rawVelocity = (targetMouse.current.x - smoothMouse.current.x) * shiftMultiplier targetVelocity.current = THREE.MathUtils.clamp(rawVelocity, -0.4, 0.4) smoothVelocity.current = THREE.MathUtils.lerp(smoothVelocity.current, targetVelocity.current, Math.min(dt * 4.0, 1.0)) materialRef.current.uniforms.u_mouse.value.copy(smoothMouse.current) materialRef.current.uniforms.u_velocity.value = smoothVelocity.current materialRef.current.uniforms.u_active.value = THREE.MathUtils.lerp( materialRef.current.uniforms.u_active.value, activeState.current, Math.min(dt * enterLeaveSpeed, 1.0) ) // Manually push reactive props to uniforms every frame materialRef.current.uniforms.u_resolution.value.set(size.width, size.height) materialRef.current.uniforms.u_slices.value = slices materialRef.current.uniforms.u_hoverRadius.value = hoverRadius materialRef.current.uniforms.u_imageZoom.value = imageZoom }) return ( (activeState.current = 1)} onPointerLeave={() => (activeState.current = 0)} onPointerCancel={() => (activeState.current = 0)} onPointerOut={() => (activeState.current = 0)} > ) } const ImageScene = ({ mediaUrl, ...props }: { mediaUrl: string } & Partial) => { const texture = useTexture(mediaUrl) return } const VideoScene = ({ mediaUrl, ...props }: { mediaUrl: string } & Partial) => { const texture = useVideoTexture(mediaUrl, { crossOrigin: "Anonymous", muted: true, loop: true, start: true }) return } export default function VelocityStrips({ mediaUrl, mediaType = "image", slices = 32, hoverRadius = 0.35, shiftMultiplier = 1.5, trackingSpeed = 2.0, imageZoom = 1.15, enterLeaveSpeed = 1.5, className, fallback }: VelocityStripsProps) { return (
{fallback} : null}> {mediaType === "video" ? ( ) : ( )}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import VelocityStrips from "@/registry/ui/velocity-strips" export default function ExamplePage() { return (
Loading image...
} />
) } ``` --- # Velocity Trail Component Context **Description:** An intense, kinetic mouse trail that calculates real-time pointer speed to deform, stretch, and squeeze items along their trajectory. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/velocity-trail.json ``` **Dependencies installed:** `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :-------------- | :--------- | :------ | :--------------------------------------------------------------------- | | `imageUrls` | `string[]` | `[]` | Array of image URLs to randomly spawn. | | `distance` | `number` | `40` | Distance the pointer must move (in pixels) before spawning a new item. | | `duration` | `number` | `800` | The total lifespan of a spawned item in milliseconds. | | `maxItems` | `number` | `15` | Maximum items on screen before forcing the oldest to fade out. | | `itemSize` | `number` | `96` | The base pixel size of the image cards. | | `className` | `string` | `""` | Optional standard Tailwind classes for the wrapper. | | `itemClassName` | `string` | `""` | Optional standard Tailwind classes for the individual image cards. | ## 3. Core Component Source **File Path:** `registry/ui/velocity-trail.tsx` ```tsx "use client" import { cn } from "@/lib/utils" import { useEffect, useRef } from "react" export interface VelocityTrailProps { imageUrls?: string[] distance?: number duration?: number maxItems?: number itemSize?: number className?: string itemClassName?: string } interface TileData { active: boolean x: number y: number rotation: number speed: number imageIndex: number zIndex: number t: number state: "enter" | "hold" | "exit" holdTime: number spawnTime: number } export default function VelocityTrail({ imageUrls = [], distance = 40, duration = 800, maxItems = 15, itemSize = 96, className, itemClassName = "", }: VelocityTrailProps) { const reqRef = useRef(null) const DOM_POOL_SIZE = maxItems * 3 const pool = useRef( Array.from({ length: DOM_POOL_SIZE }, () => ({ active: false, x: 0, y: 0, rotation: 0, speed: 0, imageIndex: 0, zIndex: 0, t: 0, state: "enter", holdTime: 0, spawnTime: 0, })) ) const domRefs = useRef<(HTMLDivElement | null)[]>([]) const state = useRef({ lastDropPos: { x: -1000, y: -1000 }, lastMovePos: { x: -1000, y: -1000 }, lastMoveTime: 0, spawnCount: 0, lastFrameTime: 0, }) const config = useRef({ imageUrls, distance, maxItems, duration, itemSize }) useEffect(() => { config.current = { imageUrls, distance, maxItems, duration, itemSize } }, [imageUrls, distance, maxItems, duration, itemSize]) useEffect(() => { const handlePointerMove = (e: PointerEvent) => { const s = state.current const c = config.current const now = Date.now() if (c.imageUrls.length === 0) return if (s.lastMovePos.x === -1000) { s.lastMovePos = { x: e.clientX, y: e.clientY } s.lastMoveTime = now return } const moveDx = e.clientX - s.lastMovePos.x const moveDy = e.clientY - s.lastMovePos.y const moveDistance = Math.hypot(moveDx, moveDy) const dt = Math.max(now - s.lastMoveTime, 1) const speed = Math.min(moveDistance / dt, 5) const trajectoryAngle = Math.atan2(moveDy, moveDx) * (180 / Math.PI) s.lastMovePos = { x: e.clientX, y: e.clientY } s.lastMoveTime = now const dropDx = e.clientX - s.lastDropPos.x const dropDy = e.clientY - s.lastDropPos.y const dropDistance = Math.hypot(dropDx, dropDy) if (dropDistance >= c.distance) { const activeNonExiting = pool.current.filter( (p) => p.active && p.state !== "exit" ) if (activeNonExiting.length >= c.maxItems) { activeNonExiting.sort((a, b) => a.spawnTime - b.spawnTime) const oldest = activeNonExiting[0] oldest.state = "exit" oldest.t = 0 } const freeIndex = pool.current.findIndex((p) => !p.active) if (freeIndex !== -1) { s.lastDropPos = { x: e.clientX, y: e.clientY } s.spawnCount += 1 pool.current[freeIndex] = { active: true, x: e.clientX, y: e.clientY, rotation: trajectoryAngle, speed: speed, imageIndex: s.spawnCount % c.imageUrls.length, zIndex: s.spawnCount, t: 0, state: "enter", holdTime: 0, spawnTime: Date.now(), } } } } window.addEventListener("pointermove", handlePointerMove) return () => window.removeEventListener("pointermove", handlePointerMove) }, []) useEffect(() => { state.current.lastFrameTime = Date.now() const animate = () => { const c = config.current const currentTime = Date.now() const delta = Math.min(currentTime - state.current.lastFrameTime, 32) state.current.lastFrameTime = currentTime const enterDuration = c.duration * 0.2 const holdDuration = c.duration * 0.5 const exitDuration = c.duration * 0.3 for (let i = 0; i < DOM_POOL_SIZE; i++) { const item = pool.current[i] const domNode = domRefs.current[i] if (!domNode) continue if (!item.active) { domNode.style.display = "none" continue } if (item.state === "enter") { item.t += delta / enterDuration if (item.t >= 1) { item.t = 1 item.state = "hold" } } else if (item.state === "hold") { item.holdTime += delta if (item.holdTime >= holdDuration) { item.state = "exit" item.t = 0 } } else if (item.state === "exit") { item.t += delta / exitDuration if (item.t >= 1) { item.t = 1 item.active = false domNode.style.display = "none" continue } } let baseScale = 1 let scaleX = 1 let scaleY = 1 let opacity = 1 let blurAmount = 0 if (item.state === "enter") { const easeOut = 1 - Math.pow(1 - item.t, 3) baseScale = easeOut const stretchFactor = item.speed * 1.5 scaleX = 1 + stretchFactor * (1 - easeOut) scaleY = 1 - Math.min(stretchFactor * 0.15 * (1 - easeOut), 0.6) blurAmount = item.speed * 2 * (1 - easeOut) opacity = easeOut } else if (item.state === "hold") { baseScale = 1 scaleX = 1 scaleY = 1 opacity = 1 } else if (item.state === "exit") { const easeIn = 1 - item.t baseScale = easeIn * easeIn scaleX = 1 + item.speed * 0.5 * (1 - baseScale) scaleY = 1 opacity = easeIn } domNode.style.display = "flex" domNode.style.zIndex = item.zIndex.toString() domNode.style.opacity = opacity.toString() domNode.style.filter = blurAmount > 0 ? \`blur(\${blurAmount}px)\` : "none" domNode.style.transform = \` translate3d(\${item.x}px, \${item.y}px, 0) rotate(\${item.rotation}deg) scaleX(\${baseScale * scaleX}) scaleY(\${baseScale * scaleY}) \` const imagesInside = domNode.querySelectorAll("img.js-trail-img") imagesInside.forEach((img, idx) => { img.style.display = idx === item.imageIndex ? "block" : "none" }) } reqRef.current = requestAnimationFrame(animate) } reqRef.current = requestAnimationFrame(animate) return () => { if (reqRef.current) cancelAnimationFrame(reqRef.current) } }, [DOM_POOL_SIZE]) useEffect(() => { imageUrls.forEach((src) => { const img = new Image() img.crossOrigin = "anonymous" img.referrerPolicy = "no-referrer" img.src = src }) }, [imageUrls]) return (
{Array.from({ length: DOM_POOL_SIZE }).map((_, i) => (
{ domRefs.current[i] = el }} className={cn( "absolute top-0 left-0 origin-center overflow-hidden bg-transparent p-0 drop-shadow-2xl will-change-transform", itemClassName )} style={{ width: \`\${itemSize}px\`, height: \`\${itemSize}px\`, marginLeft: \`-\${itemSize / 2}px\`, marginTop: \`-\${itemSize / 2}px\`, display: "none", willChange: "transform, filter, opacity", }} > {imageUrls.map((src, imgIndex) => ( trail ))}
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import VelocityTrail from "@/registry/ui/velocity-trail" const trailImages = [ "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/14.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/17.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/18.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/19.jpg", ] export default function ExamplePage() { return (

Velocity.

) } ``` --- # Wave Carousel Component Context **Description:** A high-performance, sine-wave driven infinite carousel for Satis UI. Items mathematically scale up and down based on a cosine wave, creating a "rolling hills" visual effect using strictly 2D DOM manipulation for maximum FPS. Integrates a GSAP ticker loop and GSAP Observer for flawless touch, drag, and wheel physics. Includes robust GC cleanup and reduced-motion vestibular failsafes. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/wave-carousel.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------- | :--------------------------- | :------------- | :------------------------------------------------------------ | | `items` | `WaveCarouselItem[]` | _Required_ | Image array (`{ id, url, alt }`). | | `visibleItems` | `number` | `9` | Base visible items. | | `waves` | `number` | `2` | Number of repeating sine waves on screen. | | `maxHeight` | `number` | `400` | Peak height (px). | | `minHeight` | `number` | `150` | Valley height (px). | | `breakpoints` | `Record` | `undefined` | Mobile-first overrides (e.g. `{ 640: { visibleItems: 5 } }`). | | `autoMove` | `boolean` | `false` | Enable autoplay. | | `autoMoveType` | `"continuous" \| "step"` | `"continuous"` | Autoplay behavior mode. | | `autoMoveSpeed` | `number` | `0.01` | Continuous drift velocity. | | `stepInterval` | `number` | `3000` | Delay between step snaps (ms). | | `stepDuration` | `number` | `1` | Step snap duration (s). | | `scrollMultiplier` | `number` | `0.005` | Drag sensitivity multiplier. | | `friction` | `number` | `0.95` | Momentum friction decay. | ## 3. Core Component Source **File Path:** `registry/ui/wave-carousel.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { Observer } from "gsap/Observer" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(Observer) } export interface WaveCarouselItem { id: string url: string alt?: string } export interface CarouselBreakpoint { visibleItems?: number maxHeight?: number minHeight?: number } export interface WaveCarouselProps { items: WaveCarouselItem[] visibleItems?: number waves?: number maxHeight?: number minHeight?: number breakpoints?: Record autoMove?: boolean autoMoveType?: "continuous" | "step" autoMoveSpeed?: number stepInterval?: number stepDuration?: number scrollMultiplier?: number friction?: number className?: string } export function WaveCarousel({ items, visibleItems = 9, waves = 2, maxHeight = 400, minHeight = 150, breakpoints, autoMove = false, autoMoveType = "continuous", autoMoveSpeed = 0.01, stepInterval = 3000, stepDuration = 1, scrollMultiplier = 0.005, friction = 0.95, className, }: WaveCarouselProps) { const containerRef = React.useRef(null) const wrapperRef = React.useRef(null) const itemsRef = React.useRef<(HTMLDivElement | null)[]>([]) const scrollXRef = React.useRef(0) const velocityRef = React.useRef(0) const maxRequiredVisible = React.useMemo(() => { let max = visibleItems if (breakpoints) { Object.values(breakpoints).forEach((bp) => { if (bp.visibleItems && bp.visibleItems > max) max = bp.visibleItems }) } return max }, [visibleItems, breakpoints]) const extendedItems = React.useMemo(() => { const minRequired = maxRequiredVisible * 3 let duplicated: WaveCarouselItem[] = [...items] while (duplicated.length < minRequired) { duplicated = [...duplicated, ...items] } return duplicated.map((item, i) => ({ ...item, _uniqueId: `${item.id}-${i}`, })) }, [items, maxRequiredVisible]) useGSAP( () => { if (!containerRef.current || itemsRef.current.length === 0) return const mm = gsap.matchMedia() let isReducedMotion = false mm.add("(prefers-reduced-motion: reduce)", () => { isReducedMotion = true }) let activeVis = visibleItems let activeMaxH = maxHeight let activeMinH = minHeight let activeMinScale = activeMinH / activeMaxH let windowWidth = window.innerWidth let maxWidth = 0 const updateConfig = () => { windowWidth = window.innerWidth const containerHeight = containerRef.current?.clientHeight || window.innerHeight activeVis = visibleItems let configuredMaxH = maxHeight let configuredMinH = minHeight if (breakpoints) { const bps = Object.keys(breakpoints).map(Number).sort((a, b) => a - b) for (const bp of bps) { if (windowWidth >= bp) { if (breakpoints[bp].visibleItems) activeVis = breakpoints[bp].visibleItems if (breakpoints[bp].maxHeight) configuredMaxH = breakpoints[bp].maxHeight if (breakpoints[bp].minHeight) configuredMinH = breakpoints[bp].minHeight } } } activeMaxH = Math.min(configuredMaxH, containerHeight) activeMinH = Math.min(configuredMinH, activeMaxH * 0.8) activeMinScale = activeMinH / activeMaxH const integralAvgScale = activeMinScale + (1 - activeMinScale) * 0.5 maxWidth = windowWidth / (activeVis * integralAvgScale) } const onResize = () => updateConfig() window.addEventListener("resize", onResize) onResize() let stepTween: gsap.core.Tween | null = null let stepTimer: gsap.core.Tween | null = null const scheduleNextStep = () => { if (stepTimer) stepTimer.kill() if (stepTween) stepTween.kill() if (isReducedMotion) return if (autoMove && autoMoveType === "step") { stepTimer = gsap.delayedCall(stepInterval / 1000, () => { const currentX = scrollXRef.current const targetX = Math.round(currentX) + 1 const dist = targetX - currentX let lastProxy = 0 const proxy = { x: 0 } stepTween = gsap.to(proxy, { x: dist, duration: stepDuration, ease: "power2.inOut", onUpdate: () => { const delta = proxy.x - lastProxy scrollXRef.current += delta lastProxy = proxy.x velocityRef.current = 0 }, onComplete: scheduleNextStep, }) }) } } scheduleNextStep() const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onPress: () => { if (stepTween) stepTween.kill() if (stepTimer) stepTimer.kill() }, onWheel: (e) => { if (stepTween) stepTween.kill() velocityRef.current += e.deltaY * scrollMultiplier scheduleNextStep() }, onDrag: (e) => { velocityRef.current -= e.deltaX * scrollMultiplier }, onRelease: () => scheduleNextStep(), }) const totalItems = extendedItems.length const update = () => { velocityRef.current *= friction if (Math.abs(velocityRef.current) < 0.0001) velocityRef.current = 0 let velocity = velocityRef.current if (!isReducedMotion && autoMove && autoMoveType === "continuous") { velocity += autoMoveSpeed } scrollXRef.current += velocity scrollXRef.current = ((scrollXRef.current % totalItems) + totalItems) % totalItems const scrollX = scrollXRef.current const leftIdx = Math.floor(scrollX) const rightIdx = (leftIdx + 1) % totalItems const frac = scrollX - leftIdx const layoutData = new Array(totalItems).fill({ scale: 1, width: maxWidth, x: 0, }) for (let i = 0; i < totalItems; i++) { const d1 = i - scrollX const d2 = i - (scrollX - totalItems) const d3 = i - (scrollX + totalItems) let dist = d1 if (Math.abs(d2) < Math.abs(dist)) dist = d2 if (Math.abs(d3) < Math.abs(dist)) dist = d3 const normalizedD = dist / activeVis const waveMultiplier = (Math.cos(normalizedD * Math.PI * 2 * waves) + 1) / 2 const scale = activeMinScale + (1 - activeMinScale) * waveMultiplier layoutData[i] = { scale, width: maxWidth * scale, x: 0 } } const centerScreenX = windowWidth / 2 layoutData[leftIdx].x = centerScreenX - frac * (layoutData[leftIdx].width / 2 + layoutData[rightIdx].width / 2) layoutData[rightIdx].x = layoutData[leftIdx].x + layoutData[leftIdx].width / 2 + layoutData[rightIdx].width / 2 let currR = rightIdx for (let step = 1; step <= Math.floor(totalItems / 2); step++) { const next = (currR + 1) % totalItems layoutData[next].x = layoutData[currR].x + layoutData[currR].width / 2 + layoutData[next].width / 2 currR = next } let currL = leftIdx for (let step = 1; step <= Math.floor(totalItems / 2); step++) { const prev = (currL - 1 + totalItems) % totalItems layoutData[prev].x = layoutData[currL].x - layoutData[currL].width / 2 - layoutData[prev].width / 2 currL = prev } itemsRef.current.forEach((el, i) => { if (!el) return const data = layoutData[i] const isOffScreen = data.x < -maxWidth * 2 || data.x > windowWidth + maxWidth * 2 gsap.set(el, { x: data.x - maxWidth / 2, scale: data.scale, width: maxWidth, height: activeMaxH, transformOrigin: "bottom center", autoAlpha: isOffScreen ? 0 : 1, force3D: true, }) }) } update() gsap.to(wrapperRef.current, { opacity: 1, duration: 0.5, ease: "power2.out" }) gsap.ticker.add(update) return () => { window.removeEventListener("resize", onResize) gsap.ticker.remove(update) observer.kill() if (stepTween) stepTween.kill() if (stepTimer) stepTimer.kill() } }, { scope: containerRef, dependencies: [ extendedItems, visibleItems, waves, autoMove, autoMoveType, autoMoveSpeed, stepInterval, stepDuration, scrollMultiplier, friction, maxHeight, minHeight, breakpoints, ], } ) return (
{extendedItems.map((item, index) => (
{ itemsRef.current[index] = el }} role="group" aria-roledescription="slide" aria-label={`Slide ${index + 1} of ${extendedItems.length}`} className="absolute bottom-0 flex items-end justify-center overflow-hidden will-change-transform" style={{ transformOrigin: "bottom center" }} > {/* eslint-disable-next-line @next/next/no-img-element */} {item.alt
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { WaveCarousel } from "@/registry/ui/wave-carousel" export default function ExamplePage() { return (
) } ``` --- # Weightless Float Reveal Component Context **Description:** An ambient, zero-gravity text reveal component for Satis UI. Elements drift upwards into place from randomized depths and rotations, creating an organic, weightless floating effect. Engineered with GSAP's `clearProps` cleanup, strict ARIA support, and vestibular disorder failsafes. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/weightless-float-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :----------------- | :------------------ | :------------- | :---------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML tag to render. | | `splitBy` | `"word" \| "char"` | `"char"` | Split mode. | | `startYMin` | `number` | `40` | Min starting Y offset. | | `startYMax` | `number` | `80` | Max starting Y offset. | | `startRotationMin` | `number` | `-8` | Min starting tilt. | | `startRotationMax` | `number` | `8` | Max starting tilt. | | `duration` | `number` | `2.5` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.06` | Stagger timing between items. | | `ease` | `string` | `"power3.out"` | Easing curve. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 85%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/weightless-float-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface WeightlessFloatRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" startYMin?: number startYMax?: number startRotationMin?: number startRotationMax?: number duration?: number delay?: number stagger?: number ease?: string viewportOnce?: boolean triggerStart?: string } export const WeightlessFloatReveal = React.forwardRef< HTMLElement, WeightlessFloatRevealProps >( ( { text, as = "h1", className, splitBy = "char", startYMin = 40, startYMax = 80, startRotationMin = -8, startRotationMax = 8, duration = 2.5, delay = 0, stagger = 0.06, ease = "power3.out", viewportOnce = true, triggerStart = "top 85%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".weightless-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { y: () => gsap.utils.random(startYMin, startYMax), rotation: () => gsap.utils.random(startRotationMin, startRotationMax), opacity: 0, }, { y: 0, rotation: 0, opacity: 1, duration, delay, stagger, ease, force3D: true, clearProps: "transform,opacity", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", clearProps: "transform", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ text, startYMin, startYMax, startRotationMin, startRotationMax, duration, delay, stagger, ease, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, willChange: "transform, opacity", display: "inline-block", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) WeightlessFloatReveal.displayName = "WeightlessFloatReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { WeightlessFloatReveal } from "@/registry/ui/weightless-float-reveal" export default function ExamplePage() { return (
) } ``` --- # Wind Shear Reveal Component Context **Description:** A high-velocity text reveal component for Satis UI. Elements slide in while leaning heavily against simulated wind resistance, utilizing elastic friction to snap forward into their resting positions. Features robust CSS `clearProps` anti-aliasing fixes and strict screen reader formatting. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/wind-shear-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :------------------ | :---------------- | :------------------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `splitBy` | `"word" \| "char"` | `"word"` | Split mode. | | `startX` | `string \| number` | `"1.5em"` | Starting X offset. | | `startingSkew` | `number` | `-30` | Starting skew angle (drag simulation). | | `duration` | `number` | `1.2` | Animation duration. | | `delay` | `number` | `0` | Intro delay. | | `stagger` | `number` | `0.05` | Stagger timing between items. | | `ease` | `string` | `"back.out(1.2)"` | GSAP ease function. | | `viewportOnce` | `boolean` | `true` | Toggle repeating animations. | | `triggerStart` | `string` | `"top 85%"` | Scroll trigger coordinate. | ## 3. Core Component Source **File Path:** `registry/ui/wind-shear-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export interface WindShearRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: "word" | "char" startX?: string | number startingSkew?: number duration?: number delay?: number stagger?: number ease?: string viewportOnce?: boolean triggerStart?: string } export const WindShearReveal = React.forwardRef< HTMLElement, WindShearRevealProps >( ( { text, as = "h1", className, splitBy = "word", startX = "1.5em", startingSkew = -30, duration = 1.2, delay = 0, stagger = 0.05, ease = "back.out(1.2)", viewportOnce = true, triggerStart = "top 85%", ...props }, ref ) => { const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current) return const elements = gsap.utils.toArray( ".wind-shear-item", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { x: startX, skewX: startingSkew, opacity: 0, }, { x: 0, skewX: 0, opacity: 1, duration, delay, stagger, ease, force3D: true, clearProps: "transform,opacity", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.set(elements, { x: 0, skewX: 0 }) gsap.fromTo( elements, { opacity: 0 }, { opacity: 1, duration: 0.5, delay, stagger, ease: "none", clearProps: "transform", scrollTrigger: { trigger: containerRef.current, start: triggerStart, once: viewportOnce, toggleActions: viewportOnce ? "play none none none" : "play none none reverse", }, } ) }) return () => mm.revert() }, { scope: containerRef, dependencies: [ text, startX, startingSkew, duration, delay, stagger, ease, viewportOnce, triggerStart, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: `translateX(${startX}) skewX(${startingSkew}deg)`, transformOrigin: "bottom left", willChange: "transform, opacity", display: "inline-block", } const words = text.split(/(\s+)/) const Component = as as any return ( ) } ) WindShearReveal.displayName = "WindShearReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { WindShearReveal } from "@/registry/ui/wind-shear-reveal" export default function ExamplePage() { return (
) } ``` --- # Wind Trail Component Context **Description:** An interactive mouse trail that maps spawned elements to an ambient wind physics engine, drifting items smoothly across the screen like autumn leaves. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/wind-trail.json ``` **Dependencies installed:** `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :-------------- | :--------- | :------ | :--------------------------------------------------------------------- | | `imageUrls` | `string[]` | `[]` | Array of image URLs to randomly spawn. | | `distance` | `number` | `200` | Distance the pointer must move (in pixels) before spawning a new item. | | `duration` | `number` | `2600` | The total lifespan of a spawned item in milliseconds. | | `maxItems` | `number` | `10` | Maximum items on screen before forcing the oldest to fade out. | | `itemSize` | `number` | `90` | The base pixel size of the image cards. | | `windX` | `number` | `100` | Base horizontal wind speed in pixels per second. | | `windY` | `number` | `-30` | Base vertical wind speed in pixels per second. | | `className` | `string` | `""` | Optional standard Tailwind classes for the wrapper. | | `itemClassName` | `string` | `""` | Optional standard Tailwind classes for the individual image cards. | ## 3. Core Component Source **File Path:** `registry/ui/wind-trail.tsx` ```tsx "use client" import { cn } from "@/lib/utils" import { useEffect, useRef } from "react" export interface WindTrailProps { imageUrls?: string[] distance?: number duration?: number maxItems?: number itemSize?: number windX?: number windY?: number className?: string itemClassName?: string } interface TileData { active: boolean x: number y: number driftX: number driftY: number rotation: number flutterOffset: number imageIndex: number zIndex: number t: number state: "enter" | "hold" | "exit" holdTime: number spawnTime: number } export default function WindTrail({ imageUrls = [], distance = 200, duration = 2600, maxItems = 10, itemSize = 90, windX = 100, windY = -30, className, itemClassName = "", }: WindTrailProps) { const reqRef = useRef(null) const DOM_POOL_SIZE = maxItems * 3 const pool = useRef( Array.from({ length: DOM_POOL_SIZE }, () => ({ active: false, x: 0, y: 0, driftX: 0, driftY: 0, rotation: 0, flutterOffset: 0, imageIndex: 0, zIndex: 0, t: 0, state: "enter", holdTime: 0, spawnTime: 0, })) ) const domRefs = useRef<(HTMLDivElement | null)[]>([]) const state = useRef({ lastDropPos: { x: -1000, y: -1000 }, spawnCount: 0, lastFrameTime: 0, }) const config = useRef({ imageUrls, distance, maxItems, duration, itemSize, windX, windY }) useEffect(() => { config.current = { imageUrls, distance, maxItems, duration, itemSize, windX, windY } }, [imageUrls, distance, maxItems, duration, itemSize, windX, windY]) useEffect(() => { const handlePointerMove = (e: PointerEvent) => { const s = state.current const c = config.current if (c.imageUrls.length === 0) return const dy = e.clientY - s.lastDropPos.y const dx = e.clientX - s.lastDropPos.x const moveDist = Math.hypot(dx, dy) if (moveDist >= c.distance) { const activeNonExiting = pool.current.filter( (p) => p.active && p.state !== "exit" ) if (activeNonExiting.length >= c.maxItems) { activeNonExiting.sort((a, b) => a.spawnTime - b.spawnTime) const oldest = activeNonExiting[0] oldest.state = "exit" } const freeIndex = pool.current.findIndex((p) => !p.active) if (freeIndex !== -1) { s.lastDropPos = { x: e.clientX, y: e.clientY } s.spawnCount += 1 pool.current[freeIndex] = { active: true, x: e.clientX, y: e.clientY, driftX: 0, driftY: 0, rotation: (Math.random() - 0.5) * 60, flutterOffset: Math.random() * Math.PI * 2, imageIndex: s.spawnCount % c.imageUrls.length, zIndex: s.spawnCount, t: 0, state: "enter", holdTime: 0, spawnTime: Date.now(), } } } } window.addEventListener("pointermove", handlePointerMove) return () => window.removeEventListener("pointermove", handlePointerMove) }, []) useEffect(() => { state.current.lastFrameTime = Date.now() const animate = () => { const c = config.current const currentTime = Date.now() const delta = Math.min(currentTime - state.current.lastFrameTime, 32) state.current.lastFrameTime = currentTime const dt = delta / 1000 const enterDuration = c.duration * 0.15 const holdDuration = c.duration * 0.55 const exitDuration = c.duration * 0.3 for (let i = 0; i < DOM_POOL_SIZE; i++) { const item = pool.current[i] const domNode = domRefs.current[i] if (!domNode) continue if (!item.active) { domNode.style.display = "none" continue } if (item.state === "enter") { item.t += delta / enterDuration if (item.t >= 1) { item.t = 1 item.state = "hold" } } else if (item.state === "hold") { item.holdTime += delta if (item.holdTime >= holdDuration) { item.state = "exit" } } else if (item.state === "exit") { item.t -= delta / exitDuration if (item.t <= 0) { item.t = 0 item.active = false domNode.style.display = "none" continue } } let scale = 1 let opacity = 1 if (item.state === "enter") { const easeOut = Math.sin((item.t * Math.PI) / 2) scale = 0.8 + easeOut * 0.2 opacity = easeOut } else if (item.state === "hold") { scale = 1 opacity = 1 } else if (item.state === "exit") { const easeIn = item.t * item.t scale = easeIn opacity = item.t } const turbulenceX = Math.sin(currentTime * 0.002 + item.flutterOffset) * 40 const turbulenceY = Math.cos(currentTime * 0.002 + item.flutterOffset) * 20 const windMultiplier = item.state === "exit" ? 1 + (1 - item.t) * 2 : 1 item.driftX += (c.windX + turbulenceX) * dt * windMultiplier item.driftY += (c.windY + turbulenceY) * dt * windMultiplier const flutterRotation = Math.sin(currentTime * 0.003 + item.flutterOffset) * 60 item.rotation += flutterRotation * dt * windMultiplier domNode.style.display = "flex" domNode.style.zIndex = item.zIndex.toString() domNode.style.opacity = opacity.toString() domNode.style.transform = ` translate3d(${item.x + item.driftX}px, ${item.y + item.driftY}px, 0) rotate(${item.rotation}deg) scale(${scale}) ` const imagesInside = domNode.querySelectorAll("img") imagesInside.forEach((img, idx) => { img.style.display = idx === item.imageIndex ? "block" : "none" }) } reqRef.current = requestAnimationFrame(animate) } reqRef.current = requestAnimationFrame(animate) return () => { if (reqRef.current) cancelAnimationFrame(reqRef.current) } }, [DOM_POOL_SIZE]) useEffect(() => { imageUrls.forEach((src) => { const img = new Image() img.crossOrigin = "anonymous" img.referrerPolicy = "no-referrer" img.src = src }) }, [imageUrls]) return (
{Array.from({ length: DOM_POOL_SIZE }).map((_, i) => (
{ domRefs.current[i] = el }} className={cn( "absolute top-0 left-0 overflow-hidden bg-transparent p-0 drop-shadow-2xl will-change-transform", itemClassName )} style={{ width: `${itemSize}px`, height: `${itemSize}px`, marginLeft: `-${itemSize / 2}px`, marginTop: `-${itemSize / 2}px`, borderRadius: "15%", display: "none", willChange: "transform, opacity", }} > {imageUrls.map((src, imgIndex) => ( trail ))}
))}
) } ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import WindTrail from "@/registry/ui/wind-trail" const trailImages = [ "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/14.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/17.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/18.jpg", "https://res.cloudinary.com/ddon6aux0/image/upload/w_250,f_auto,q_auto/v1781471531/ui-v3/demos/images/19.jpg", ] export default function ExamplePage() { return (

Autumn Gust.

) } ``` --- # Z-Axis Reveal Component Context **Description:** A cinematic deep-space text reveal component for Satis UI. Elements fly in from the Z-axis, scaling down and un-blurring into focus. Fully optimizes GSAP's pinning features and utilizes clearProps matrix resets for pristine final anti-aliasing. ## 1. Installation To add this component to a project, run: ```bash npx shadcn@latest add https://ui.satisium.com/r/z-axis-reveal.json ``` **Dependencies installed:** `gsap`, `@gsap/react`, `clsx`, `tailwind-merge`. ## 2. Props API | Prop | Type | Default | Description | | :------------- | :--------------------------- | :---------------- | :---------------------------------------- | | `text` | `string` | _Required_ | The text string. | | `as` | `React.ElementType` | `"h1"` | HTML element to render. | | `splitBy` | `"char" \| "word" \| "line"` | `"word"` | Split mode. | | `momentum` | `number` | `1.5` | Inertia scroll catch-up time. | | `startScale` | `number` | `3` | Starting scale value. | | `blur` | `boolean` | `true` | Initial deep space blur. | | `pin` | `boolean` | `true` | DOM pinning for scrollytelling. | | `triggerStart` | `string` | `"center center"` | Scroll trigger coordinate. | | `triggerEnd` | `string` | _Dynamic_ | Auto-calculates based on children length. | ## 3. Core Component Source **File Path:** `registry/ui/z-axis-reveal.tsx` ```tsx "use client" import * as React from "react" import gsap from "gsap" import { useGSAP } from "@gsap/react" import { ScrollTrigger } from "gsap/ScrollTrigger" import { cn } from "@/lib/utils" if (typeof window !== "undefined") { gsap.registerPlugin(ScrollTrigger) } export type ZAxisSplitType = "char" | "word" | "line" export interface ZAxisRevealProps extends React.HTMLAttributes { text: string as?: React.ElementType splitBy?: ZAxisSplitType momentum?: number startScale?: number blur?: boolean pin?: boolean triggerStart?: string triggerEnd?: string } export const ZAxisReveal = React.forwardRef( ( { text, as = "h1", className, splitBy = "word", momentum = 1.5, startScale = 3, blur = true, pin = true, triggerStart = pin ? "center center" : "top 85%", triggerEnd, ...props }, ref ) => { const triggerRef = React.useRef(null) const containerRef = React.useRef(null) React.useImperativeHandle(ref, () => containerRef.current) useGSAP( () => { if (!containerRef.current || !triggerRef.current) return const elements = gsap.utils.toArray( ".z-axis-target", containerRef.current ) if (elements.length === 0) return const mm = gsap.matchMedia() const distanceMultiplier = splitBy === "char" ? 20 : splitBy === "word" ? 40 : 150 const calculatedEnd = pin ? `+=${elements.length * distanceMultiplier}` : "bottom 60%" const finalEnd = triggerEnd || calculatedEnd mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.fromTo( elements, { opacity: 0, scale: startScale, filter: blur ? "blur(20px)" : "blur(0px)", z: 1, }, { opacity: 1, scale: 1, filter: "blur(0px)", z: 0, stagger: 0.1, ease: "power3.out", force3D: true, clearProps: blur ? "filter,transform" : "transform", scrollTrigger: { trigger: triggerRef.current, pin: pin, anticipatePin: 1, start: triggerStart, end: finalEnd, scrub: momentum, invalidateOnRefresh: true, }, } ) }) mm.add("(prefers-reduced-motion: reduce)", () => { gsap.fromTo( elements, { opacity: 0, scale: 1, filter: "none" }, { opacity: 1, ease: "none", stagger: 0.1, clearProps: "transform", scrollTrigger: { trigger: triggerRef.current, pin: pin, start: triggerStart, end: finalEnd, scrub: momentum, }, } ) }) return () => mm.revert() }, { scope: triggerRef, dependencies: [ blur, momentum, startScale, triggerStart, triggerEnd, pin, splitBy, text, ], } ) const ssrInitialStyles: React.CSSProperties = { opacity: 0, transform: `scale(${startScale}) translateZ(0)`, filter: blur ? "blur(20px)" : "none", transformOrigin: "center center", willChange: "transform, opacity, filter", } const renderContent = () => { if (splitBy === "line") { return text.split("\n").map((line, idx) => ( {line} )) } const words = text.split(/(\s+)/) return words.map((word, wordIdx) => { if (word.match(/\s+/)) { return ( {word} ) } if (splitBy === "char") { return ( {word.split("").map((char, charIdx) => ( {char} ))} ) } return ( {word} ) }) } const Component = as as any return (
) } ) ZAxisReveal.displayName = "ZAxisReveal" ``` ## 4. Example Implementation **File Path:** `app/page.tsx` ```tsx "use client" import { ZAxisReveal } from "@/registry/ui/z-axis-reveal" export default function ExamplePage() { return (
) } ``` ---