Genie Effect
Installation
npx shadcn@latest add https://uibrik.com/r/genie-effect.jsonInstall the packages
npm install @tabler/icons-reactAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Set up squircle corners
Needs
@squircle/core,@squircle/tailwindcssand a few lines of CSS. See squircle setup.Copy the source into your project
components/uibrik/genie-effect/genie-dock.tsx"use client"; import { IconX } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { useCallback, useEffect, useRef, useState } from "react"; import GenieEffect, { type Rect } from "./GenieEffect"; import { renderWindowTexture } from "./windowSnapshot"; export interface DockItem { id: string | number; src: string; alt: string; } // Extensible images list - add new items here easily export const DOCK_ITEMS: DockItem[] = [ { id: 1, src: "https://uibrik.com/images/1.jpg", alt: "Image 1", }, { id: 2, src: "https://uibrik.com/images/2.jpg", alt: "Image 2", }, { id: 3, src: "https://uibrik.com/images/3.jpg", alt: "Image 3", }, { id: 4, src: "https://uibrik.com/images/4.jpg", alt: "Image 4", }, { id: 5, src: "https://uibrik.com/images/5.jpg", alt: "Image 5", }, ]; // Tuned Genie settings const GENIE = { windowTop: 200, windowSize: 450, duration: 0.52, curvature: 0.89, pinch: 0.72, verticalStretch: 2.0, meshResolution: 100, }; interface AnimatingState { isAnimating: boolean; direction: "minimize" | "restore"; fromRect: Rect; toRect: Rect; texture: HTMLCanvasElement | null; targetId: string | number; } export type GenieDockProps = { /** Images in the dock. */ items?: DockItem[]; /** Seconds for one minimize or restore. */ duration?: number; /** Bend of the funnel, 0 to 1. */ curvature?: number; /** How narrow the neck gets, 0 to 1. */ pinch?: number; /** Vertical smear during travel. */ verticalStretch?: number; /** Rows in the warp mesh. */ meshResolution?: number; /** Window size in px (square). */ windowSize?: number; /** Window centre, in px from the top of the 520px stage. */ windowTop?: number; }; export function GenieDock({ items = DOCK_ITEMS, duration = GENIE.duration, curvature = GENIE.curvature, pinch = GENIE.pinch, verticalStretch = GENIE.verticalStretch, meshResolution = GENIE.meshResolution, windowSize = GENIE.windowSize, windowTop = GENIE.windowTop, }: GenieDockProps) { const animDuration = duration * 1000; const animCurvature = curvature; const animPinch = pinch; const animVerticalStretch = verticalStretch; const animMeshResolution = meshResolution; const winSize = windowSize; const winTop = windowTop; // Initially none are open (Apple style) const [isOpen, setIsOpen] = useState(false); const [activeId, setActiveId] = useState<string | number | null>(null); const [animatingState, setAnimatingState] = useState<AnimatingState | null>(null); const containerRef = useRef<HTMLDivElement>(null); const windowRef = useRef<HTMLDivElement>(null); const dockRefs = useRef<{ [key: string | number]: HTMLDivElement | null }>({}); const cachedWindowRect = useRef<Rect | null>(null); // Pre-cache texture canvases for instant animation start const texturesRef = useRef<{ [key: string | number]: HTMLCanvasElement }>({}); useEffect(() => { items.forEach(async (item) => { const texture = await renderWindowTexture(item.src, winSize, winSize); texturesRef.current[item.id] = texture; }); }, [winSize]); const activeItem = items.find((item) => item.id === activeId) ?? null; const getWindowRect = useCallback((): Rect => { if (windowRef.current) { const r = windowRef.current.getBoundingClientRect(); if (r.width > 0 && r.height > 0) { const rect = { x: r.left, y: r.top, width: r.width, height: r.height }; cachedWindowRect.current = rect; return rect; } } if (cachedWindowRect.current) return cachedWindowRect.current; const x = (window.innerWidth - winSize) / 2; const y = (window.innerHeight - 520) / 2 + winTop - winSize / 2; return { x, y, width: winSize, height: winSize }; }, [winSize, winTop]); const getDockRect = useCallback((id: string | number): Rect => { const el = dockRefs.current[id]; if (el) { const r = el.getBoundingClientRect(); return { x: r.left, y: r.top, width: r.width, height: r.height }; } return { x: window.innerWidth / 2, y: window.innerHeight - 100, width: 64, height: 64 }; }, []); const handleMinimize = useCallback( async (item: DockItem) => { if (animatingState?.isAnimating) return; const winRect = getWindowRect(); const dockRect = getDockRect(item.id); let texture = texturesRef.current[item.id]; if (!texture) { texture = await renderWindowTexture(item.src, winRect.width, winRect.height); texturesRef.current[item.id] = texture; } setIsOpen(false); setAnimatingState({ isAnimating: true, direction: "minimize", fromRect: winRect, toRect: dockRect, texture, targetId: item.id, }); }, [animatingState, getDockRect, getWindowRect], ); const handleRestore = useCallback( async (item: DockItem) => { if (animatingState?.isAnimating) return; const winRect = getWindowRect(); const dockRect = getDockRect(item.id); let texture = texturesRef.current[item.id]; if (!texture) { texture = await renderWindowTexture(item.src, winRect.width, winRect.height); texturesRef.current[item.id] = texture; } setActiveId(item.id); setIsOpen(false); setAnimatingState({ isAnimating: true, direction: "restore", fromRect: winRect, toRect: dockRect, texture, targetId: item.id, }); }, [animatingState, getDockRect, getWindowRect], ); const handleDockClick = useCallback( (item: DockItem) => { if (animatingState?.isAnimating) return; if (isOpen) { if (activeId === item.id) { handleMinimize(item); } else { if (activeItem) { handleMinimize(activeItem); setTimeout(() => { handleRestore(item); }, animDuration + 50); } else { handleRestore(item); } } } else { handleRestore(item); } }, [activeId, activeItem, animDuration, animatingState, handleMinimize, handleRestore, isOpen], ); const onAnimationComplete = useCallback(() => { if (animatingState?.direction === "restore") { setActiveId(animatingState.targetId); setIsOpen(true); } else if (animatingState?.direction === "minimize") { setActiveId(null); setIsOpen(false); } setAnimatingState(null); }, [animatingState]); return ( <> <div ref={containerRef} className="flex gap-1.5 size-130 items-end justify-center relative" > {/* Interactive Window: - Uses squircle-4xl with squircle-mask for pixel-perfect edge clipping */} <div ref={windowRef} style={{ top: `${winTop}px`, width: `${winSize}px`, height: `${winSize}px`, }} className={cn( "group squircle-4xl squircle-mask bg-neutral-100 dark:bg-neutral-900 absolute left-1/2 -translate-x-1/2 -translate-y-1/2 overflow-hidden", (!isOpen || animatingState?.isAnimating || activeItem === null) && "invisible pointer-events-none", )} > {activeItem !== null && ( // eslint-disable-next-line @next/next/no-img-element <img src={activeItem.src} alt={activeItem.alt} fetchPriority="high" className="absolute inset-0 size-full object-cover" /> )} {/* Close / Minimize Button: revealed on hover */} <button type="button" onClick={(e) => { e.stopPropagation(); if (activeItem !== null) handleMinimize(activeItem); }} className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-white/15 backdrop-blur-xs text-zinc-50 rounded-full flex items-center justify-center absolute top-4 right-4 z-10 size-8.5 cursor-pointer hover:bg-white/25 hover:text-zinc-50 active:scale-95 transition-colors" aria-label="Close / Minimize" > <CrossIcon /> </button> </div> {/* Dock Items: - Rendered from extensible items array of objects - Space is preserved when an item is open */} <div className="flex gap-1.5 items-end justify-center"> {items.map((item) => { const isHoldingOpen = (isOpen && activeId === item.id) || (animatingState?.isAnimating && animatingState.targetId === item.id); return ( <div key={item.id} ref={(el) => { dockRefs.current[item.id] = el; }} onClick={() => !isHoldingOpen && handleDockClick(item)} className={cn( "relative overflow-hidden size-16 squircle-2xl squircle-mask transition-opacity duration-150", isHoldingOpen ? "invisible pointer-events-none" : "cursor-pointer active:scale-95 hover:brightness-105", )} > {/* eslint-disable-next-line @next/next/no-img-element */} <img src={item.src} alt={item.alt} className="absolute inset-0 size-full object-cover" /> </div> ); })} </div> </div> {/* Hardware-Accelerated Genie Canvas Animation */} {animatingState?.isAnimating && ( <GenieEffect fromRect={animatingState.fromRect} toRect={animatingState.toRect} direction={animatingState.direction} duration={animDuration} curvature={animCurvature} pinch={animPinch} verticalStretch={animVerticalStretch} meshResolution={animMeshResolution} textureCanvasOrImage={animatingState.texture} onComplete={onAnimationComplete} /> )} </> ); } const CrossIcon = ({ className }: { className?: string; size?: number }) => ( <IconX aria-hidden="true" size={18} stroke={2} className={className} /> );
Adds the files to
components/uibrik/genie-effect and installs @tabler/icons-react. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/genie-effect.Props
GenieDock- duration
- Type number
- 0.52
- Length of one minimize or restore.
- curvature
- Type number
- 0.89
- Bend of the funnel.
- pinch
- Type number
- 0.72
- How narrow the neck gets.
- verticalStretch
- Type number
- 2
- Vertical smear during travel.
- meshResolution
- Type number
- 100
- Rows in the warp mesh.
- windowSize
- Type number
- 450
- Window size (square).
- items
- Type DockItem[]
- DOCK_ITEMS
- Images in the dock: id, src, alt.