Merge Gallery
Installation
npx shadcn@latest add https://uibrik.com/r/merge-gallery.jsonInstall the packages
npm install motionCopy the source into your project
components/uibrik/merge-gallery/MergeGallery.tsx"use client"; import { useEffect, useRef, useState } from "react"; import { animate } from "motion/react"; const TILE = 144; const GAP = 14; const W = 3 * TILE + 2 * GAP; const H = 2 * TILE + GAP; const PAD = 20; // container padding const OUTER_RADIUS = 50; // container corner radius const RADIUS = OUTER_RADIUS - PAD; // tiles are concentric with the container const MAX_DPR = 5; // normal screens are 1 to 3; the video recorder renders far above that const BLUR_LIMIT = 24; // largest `blur` prop the buffers are sized for const MARGIN = BLUR_LIMIT * 3; // edge extension so blur never fades the picture's own border export type GalleryVideo = { src: string; poster: string }; export const SAMPLE_GALLERY_VIDEOS: GalleryVideo[] = [1, 2, 3, 4, 5, 6].map((n) => ({ src: `https://uibrik.com/registry/merge-gallery/v${n}.mp4`, poster: `https://uibrik.com/registry/merge-gallery/v${n}.jpg`, })); // each tile grows only into the gap midline (+ small overlap) so outer edges never move const cols = [ { from: [0, TILE], to: [0, TILE + GAP / 2 + 3] }, { from: [TILE + GAP, 2 * TILE + GAP], to: [TILE + GAP / 2 - 3, 2 * TILE + GAP * 1.5 + 3] }, { from: [2 * TILE + 2 * GAP, W], to: [2 * TILE + GAP * 1.5 - 3, W] }, ]; const rows = [ { from: [0, TILE], to: [0, TILE + GAP / 2 + 3] }, { from: [TILE + GAP, H], to: [TILE + GAP / 2 - 3, H] }, ]; type Rect = { x: number; y: number; w: number; h: number }; const lerp = (a: number, b: number, t: number) => a + (b - a) * t; const smooth = (a: number, b: number, t: number) => { const x = Math.min(1, Math.max(0, (t - a) / (b - a))); return x * x * (3 - 2 * x); }; const tileRect = (i: number, p: number): Rect => { const c = cols[i % 3]; const r = rows[Math.floor(i / 3)]; const x0 = lerp(c.from[0], c.to[0], p); const y0 = lerp(r.from[0], r.to[0], p); return { x: x0, y: y0, w: lerp(c.from[1], c.to[1], p) - x0, h: lerp(r.from[1], r.to[1], p) - y0 }; }; // Safari may expose `ctx.filter` without applying it, so test whether blur really happens let nativeBlur: boolean | null = null; const supportsCanvasFilter = () => { if (nativeBlur !== null) return nativeBlur; try { const c = document.createElement("canvas"); c.width = 16; c.height = 16; const t = c.getContext("2d"); if (!t || typeof t.filter !== "string") return (nativeBlur = false); t.filter = "blur(2px)"; t.fillStyle = "#fff"; t.fillRect(5, 5, 6, 6); nativeBlur = t.getImageData(3, 8, 1, 1).data[3] > 0; } catch { nativeBlur = false; } return nativeBlur; }; // continuous-corner ("squircle") outline, same construction and 0.6 smoothing as the CSS squircle const SMOOTHING = 0.6; const rad = (deg: number) => (deg * Math.PI) / 180; const cornerParams = (radius: number, w: number, h: number) => { const R = Math.max(0, Math.min(radius, Math.min(w, h) / 2 / (1 + SMOOTHING))); const p = (1 + SMOOTHING) * R; const arcMeasure = 90 * (1 - SMOOTHING); const arc = Math.sin(rad(arcMeasure / 2)) * R * Math.SQRT2; const alpha = (90 - arcMeasure) / 2; const p3p4 = R * Math.tan(rad(alpha / 2)); const beta = 45 * SMOOTHING; const c = p3p4 * Math.cos(rad(beta)); const d = c * Math.tan(rad(beta)); const b = (p - arc - c - d) / 3; return { R, p, arc, a: 2 * b, b, c, d }; }; const squirclePathData = (x: number, y: number, w: number, h: number, radii: number[]) => { const [tl, tr, br, bl] = radii.map((r) => cornerParams(r, w, h)); const f = (n: number) => n.toFixed(3); const seg = (k: ReturnType<typeof cornerParams>, rot: number) => { if (k.R < 0.01) return ""; const { a, b, c, d, arc, R } = k; // top-right corner vectors, rotated by 90deg steps for the other corners const pts = [ [a, 0, a + b, 0, a + b + c, d], [arc, arc], [d, c, d, b + c, d, a + b + c], ]; const turn = (vx: number, vy: number): [number, number] => { let X = vx; let Y = vy; for (let i = 0; i < rot; i++) [X, Y] = [-Y, X]; return [X, Y]; }; const cur = (v: number[]) => { const out: number[] = []; for (let i = 0; i < v.length; i += 2) out.push(...turn(v[i], v[i + 1])); return out.map(f).join(" "); }; return `c ${cur(pts[0])} a ${f(R)} ${f(R)} 0 0 1 ${cur(pts[1])} c ${cur(pts[2])} `; }; return ( `M ${f(x + w - tr.p)} ${f(y)} ${seg(tr, 0)}L ${f(x + w)} ${f(y + h - br.p)} ${seg(br, 1)}` + `L ${f(x + bl.p)} ${f(y + h)} ${seg(bl, 2)}L ${f(x)} ${f(y + tl.p)} ${seg(tl, 3)}Z` ); }; const squirclePath = (x: number, y: number, w: number, h: number, radii: number[]) => new Path2D(squirclePathData(x, y, w, h, radii)); // the container outline: same construction as the tiles, so its corners stay concentric with them const CONTAINER_W = W + PAD * 2; const CONTAINER_H = H + PAD * 2; const CONTAINER_CLIP = `path("${squirclePathData(0, 0, CONTAINER_W, CONTAINER_H, [OUTER_RADIUS, OUTER_RADIUS, OUTER_RADIUS, OUTER_RADIUS])}")`; const coverRect = (sw: number, sh: number, box: Rect): Rect => { const scale = Math.max(box.w / sw, box.h / sh); const w = sw * scale; const h = sh * scale; return { x: box.x + (box.w - w) / 2, y: box.y + (box.h - h) / 2, w, h }; }; export type MergeGalleryProps = { /** Exactly six videos, laid out 3 × 2. Posters are drawn until a video plays. */ videos?: GalleryVideo[]; /** Seconds the tiles take to merge into the player, and to split back. */ duration?: number; /** Peak blur while the tiles merge, in px. */ blur?: number; /** How dark a tile gets under the pointer, 0 to 1. */ dim?: number; /** Loop the open video. */ loop?: boolean; className?: string; }; /** Six tiles that merge into one video player, and split back apart. */ export function MergeGallery({ videos = SAMPLE_GALLERY_VIDEOS, duration: DURATION = 0.55, blur = 11, dim = 0.6, loop = true, className, }: MergeGalleryProps) { const MAX_BLUR = Math.min(blur, BLUR_LIMIT); const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const [paused, setPaused] = useState(false); const progress = useRef(0); const activeRef = useRef(0); const canvasRef = useRef<HTMLCanvasElement>(null); const blockRef = useRef<HTMLCanvasElement | null>(null); const extRef = useRef<HTMLCanvasElement | null>(null); const pyramidRef = useRef<HTMLCanvasElement[]>([]); const hoverRef = useRef<number[]>([0, 0, 0, 0, 0, 0]); const lastFrameRef = useRef<HTMLCanvasElement | null>(null); const lastForRef = useRef(-1); const fillRef = useRef<HTMLDivElement>(null); const thumbRef = useRef<HTMLDivElement>(null); const timeRef = useRef<HTMLSpanElement>(null); const trackRef = useRef<HTMLDivElement>(null); const leftIconRef = useRef<SVGPathElement>(null); const rightIconRef = useRef<SVGPathElement>(null); const morphRef = useRef(1); // 1 = pause icon, 0 = play icon const draggingRef = useRef(false); const imagesRef = useRef<HTMLImageElement[]>([]); const videoRef = useRef<HTMLVideoElement>(null); const dprRef = useRef(1); // the six frames. Each tile is a window into its picture laid out exactly like the final // video frame (same scale, same position), so merging only reveals more of the same // picture: nothing zooms and nothing shifts when the video takes over. const drawTiles = (ctx: CanvasRenderingContext2D, g: number, outer: number) => { for (let i = 0; i < 6; i++) { const image = imagesRef.current[i]; if (!image || !image.complete) continue; const c = i % 3; const r = Math.floor(i / 3); const inner = RADIUS * (1 - g); const radii = [ c === 0 && r === 0 ? outer : inner, c === 2 && r === 0 ? outer : inner, c === 2 && r === 1 ? outer : inner, c === 0 && r === 1 ? outer : inner, ]; const t = tileRect(i, g); const at = coverRect(image.naturalWidth, image.naturalHeight, { x: 0, y: 0, w: W, h: H }); ctx.save(); ctx.clip(squirclePath(t.x, t.y, t.w, t.h, radii)); ctx.drawImage(image, at.x, at.y, at.w, at.h); if (hoverRef.current[i] > 0.001) { ctx.fillStyle = `rgba(0, 0, 0, ${dim * hoverRef.current[i]})`; ctx.fillRect(t.x, t.y, t.w, t.h); } ctx.restore(); } }; // paints `block` onto the main canvas blurred, after extending its border pixels outward const drawBlurred = ( ctx: CanvasRenderingContext2D, paint: (c: CanvasRenderingContext2D) => void, blur: number, alpha: number, ) => { const block = blockRef.current; const ext = extRef.current; const bctx = block?.getContext("2d"); const ectx = ext?.getContext("2d"); if (!block || !ext || !bctx || !ectx) return; const dpr = dprRef.current; bctx.setTransform(dpr, 0, 0, dpr, 0, 0); bctx.clearRect(0, 0, W, H); paint(bctx); const bw = block.width; const bh = block.height; ectx.setTransform(dpr, 0, 0, dpr, 0, 0); ectx.clearRect(0, 0, W + MARGIN * 2, H + MARGIN * 2); const m = MARGIN; ectx.drawImage(block, 0, 0, bw, bh, m, m, W, H); ectx.drawImage(block, 0, 0, 1, bh, 0, m, m, H); // left ectx.drawImage(block, bw - 1, 0, 1, bh, m + W, m, m, H); // right ectx.drawImage(block, 0, 0, bw, 1, m, 0, W, m); // top ectx.drawImage(block, 0, bh - 1, bw, 1, m, m + H, W, m); // bottom ectx.drawImage(block, 0, 0, 1, 1, 0, 0, m, m); ectx.drawImage(block, bw - 1, 0, 1, 1, m + W, 0, m, m); ectx.drawImage(block, 0, bh - 1, 1, 1, 0, m + H, m, m); ectx.drawImage(block, bw - 1, bh - 1, 1, 1, m + W, m + H, m, m); ctx.save(); ctx.globalAlpha = alpha; if (blur <= 0.01) { ctx.drawImage(ext, -m, -m, W + m * 2, H + m * 2); } else if (supportsCanvasFilter()) { ctx.filter = `blur(${blur}px)`; ctx.drawImage(ext, -m, -m, W + m * 2, H + m * 2); } else { // no canvas filter (Safari): blur by halving the picture a few times and scaling it back up const levels = Math.max(1, Math.min(2, Math.round(Math.log2(1 + blur * 0.6)))); const pyr = pyramidRef.current; let prev: HTMLCanvasElement = ext; for (let k = 1; k <= levels; k++) { const c = (pyr[k] ??= document.createElement("canvas")); c.width = Math.max(2, Math.ceil(ext.width / 2 ** k)); c.height = Math.max(2, Math.ceil(ext.height / 2 ** k)); const cctx = c.getContext("2d"); if (!cctx) return; cctx.imageSmoothingEnabled = true; cctx.drawImage(prev, 0, 0, c.width, c.height); prev = c; } for (let k = levels - 1; k >= 1; k--) { const c = pyr[k]; const cctx = c.getContext("2d"); if (!cctx) return; cctx.clearRect(0, 0, c.width, c.height); cctx.drawImage(prev, 0, 0, c.width, c.height); prev = c; } ctx.imageSmoothingEnabled = true; ctx.drawImage(prev, -m, -m, W + m * 2, H + m * 2); } ctx.restore(); }; const draw = (p: number) => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) return; const dpr = dprRef.current; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H); if (p <= 0) { drawTiles(ctx, 0, RADIUS); return; } const g = smooth(0, 0.65, p); // tiles grow into the gaps const out = smooth(0.1, 0.85, p); // merged block blurs const into = smooth(0.3, 1, p); // video fades up and sharpens // everything lives inside one fixed outer frame, so the outer edges stay crisp ctx.save(); ctx.clip(squirclePath(0, 0, W, H, [RADIUS, RADIUS, RADIUS, RADIUS])); if (into < 1) drawBlurred(ctx, (c) => drawTiles(c, g, 0), out * MAX_BLUR, 1); if (into > 0) { const video = videoRef.current; const ready = video && video.readyState >= 2 && video.dataset.i === String(activeRef.current); const image = imagesRef.current[activeRef.current]; const last = lastFrameRef.current; const lctx = last?.getContext("2d"); if (ready && last && lctx) { lctx.drawImage(video, 0, 0, last.width, last.height); lastForRef.current = activeRef.current; } const useLast = !!last && lastForRef.current === activeRef.current; const source = useLast ? last : image?.complete ? image : null; if (source) { const sw = useLast ? last.width : image.naturalWidth; const sh = useLast ? last.height : image.naturalHeight; const at = coverRect(sw, sh, { x: 0, y: 0, w: W, h: H }); drawBlurred(ctx, (c) => c.drawImage(source, at.x, at.y, at.w, at.h), (1 - into) * MAX_BLUR, into); } } ctx.restore(); }; useEffect(() => { const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); dprRef.current = dpr; const canvas = canvasRef.current; if (canvas) { canvas.width = W * dpr; canvas.height = H * dpr; } const block = document.createElement("canvas"); block.width = W * dpr; block.height = H * dpr; blockRef.current = block; const ext = document.createElement("canvas"); ext.width = (W + MARGIN * 2) * dpr; ext.height = (H + MARGIN * 2) * dpr; extRef.current = ext; const last = document.createElement("canvas"); last.width = 1200; last.height = 800; lastFrameRef.current = last; imagesRef.current = videos.map((video) => { const image = new Image(); image.onload = () => draw(progress.current); image.src = video.poster; return image; }); draw(0); renderIcon(1); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // the video is painted into the canvas, so repaint every frame while it is on screen useEffect(() => { if (!open && !busy) return; let frame = requestAnimationFrame(function tick() { draw(progress.current); updateUi(); frame = requestAnimationFrame(tick); }); return () => cancelAnimationFrame(frame); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, busy]); const fmt = (t: number) => { const total = Math.max(0, Math.floor(t)); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; }; const updateUi = () => { const video = videoRef.current; if (!video || !video.duration) return; const ratio = Math.min(1, Math.max(0, video.currentTime / video.duration)); if (fillRef.current) fillRef.current.style.width = `${ratio * 100}%`; if (thumbRef.current) thumbRef.current.style.left = `${ratio * 100}%`; trackRef.current?.setAttribute("aria-valuenow", String(Math.round(ratio * 100))); if (timeRef.current) timeRef.current.textContent = `${fmt(video.currentTime)} / ${fmt(video.duration)}`; }; // play and pause icons share the same two shapes, so they morph into each other const renderIcon = (m: number) => { const mix = (a: number[], b: number[]) => a.map((v, i) => lerp(v, b[i], m)); // m=1 -> pause, m=0 -> play const pts = (v: number[]) => `M${v[0]} ${v[1]}L${v[2]} ${v[3]}L${v[4]} ${v[5]}L${v[6]} ${v[7]}Z`; leftIconRef.current?.setAttribute( "d", pts(mix([7, 5, 12, 8.4, 12, 15.6, 7, 19], [6, 5, 10, 5, 10, 19, 6, 19])), ); rightIconRef.current?.setAttribute( "d", pts(mix([12, 8.4, 18, 12, 18, 12, 12, 15.6], [14, 5, 18, 5, 18, 19, 14, 19])), ); }; const morphTo = (to: number) => { animate(morphRef.current, to, { duration: 0.28, ease: [0.4, 0, 0.2, 1], onUpdate: (m) => { morphRef.current = m; renderIcon(m); }, }); }; const togglePlay = () => { const video = videoRef.current; if (!video || !open || busy) return; if (video.paused) { video.play().catch(() => {}); setPaused(false); morphTo(1); } else { video.pause(); setPaused(true); morphTo(0); } }; const seekFrom = (clientX: number) => { const video = videoRef.current; const track = trackRef.current; if (!video || !track || !video.duration) return; const rect = track.getBoundingClientRect(); const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); video.currentTime = ratio * video.duration; updateUi(); }; const ensureSrc = (i: number) => { const video = videoRef.current; if (!video || video.dataset.i === String(i)) return; video.dataset.i = String(i); video.src = videos[i].src; video.load(); }; const run = (to: number, onDone: () => void) => { setBusy(true); animate(progress.current, to, { duration: DURATION, ease: [0.4, 0, 0.2, 1], onUpdate: (p) => { progress.current = p; }, onComplete: () => { progress.current = to; draw(to); setBusy(false); onDone(); }, }); }; const hoverTo = (i: number, to: number) => { animate(hoverRef.current[i], to, { duration: 0.3, ease: "easeOut", onUpdate: (v) => { hoverRef.current[i] = v; if (progress.current === 0) draw(0); }, }); }; const expand = (i: number) => { if (busy || open) return; activeRef.current = i; for (let k = 0; k < 6; k++) if (hoverRef.current[k] > 0) hoverTo(k, 0); setPaused(false); morphRef.current = 1; renderIcon(1); setOpen(true); ensureSrc(i); const video = videoRef.current; if (video) { video.currentTime = 0; video.play().catch(() => {}); } run(1, () => {}); }; const collapse = () => { if (busy || !open) return; setOpen(false); run(0, () => { const video = videoRef.current; video?.pause(); if (video) video.currentTime = 0; }); }; return ( <div className={`flex flex-col items-center gap-4 ${className ?? ""}`}> <div className="bg-neutral-100 dark:bg-neutral-900" style={{ padding: PAD, clipPath: CONTAINER_CLIP }}> <div className="group/frame relative" style={{ width: W, height: H }}> <canvas ref={canvasRef} className="absolute inset-0 size-full" /> {/* decoder only: frames are painted into the canvas above */} <video ref={videoRef} muted loop={loop} playsInline preload="none" className="pointer-events-none absolute size-px opacity-0" /> {/* video controls: click the picture to pause, scrub along the bottom */} <div className={`absolute inset-0 ${open && !busy ? "" : "pointer-events-none"}`} onClick={togglePlay} > <div onClick={(e) => e.stopPropagation()} className={`absolute inset-x-0 bottom-0 flex items-center gap-3 rounded-b-4xl bg-gradient-to-t from-black/55 to-transparent px-5 pb-4 pt-10 transition-opacity duration-200 ease-out ${ open && !busy ? (paused ? "opacity-100" : "opacity-0 group-hover/frame:opacity-100") : "opacity-0" }`} > <button type="button" onClick={togglePlay} aria-label={paused ? "Play" : "Pause"} className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-white transition-colors duration-150 hover:bg-white/20" > <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <path ref={leftIconRef} /> <path ref={rightIconRef} /> </svg> </button> <div ref={trackRef} role="slider" aria-label="Seek" aria-valuemin={0} aria-valuemax={100} aria-valuenow={0} tabIndex={0} onPointerDown={(e) => { draggingRef.current = true; e.currentTarget.setPointerCapture(e.pointerId); seekFrom(e.clientX); }} onPointerMove={(e) => draggingRef.current && seekFrom(e.clientX)} onPointerUp={() => (draggingRef.current = false)} onPointerCancel={() => (draggingRef.current = false)} className="group/track relative flex h-5 flex-1 cursor-pointer touch-none items-center" > <div className="relative h-1 w-full rounded-full bg-white/30 transition-[height] duration-150 group-hover/track:h-1.5"> <div ref={fillRef} className="h-full rounded-full bg-white" style={{ width: "0%" }} /> <div ref={thumbRef} className="absolute top-1/2 h-3.5 w-5 -translate-x-1/2 -translate-y-1/2 scale-x-50 rounded-full bg-white opacity-0 shadow-sm transition-[opacity,transform] duration-150 group-hover/track:scale-x-100 group-hover/track:opacity-100" style={{ left: "0%" }} /> </div> </div> <span ref={timeRef} className="shrink-0 text-xs tabular-nums text-white/85"> 0:00 / 0:00 </span> </div> </div> <div className={`absolute inset-0 grid grid-cols-3 ${open ? "pointer-events-none" : ""}`} style={{ gap: GAP }} > {videos.map((video, i) => ( <button key={video.src} type="button" onClick={() => expand(i)} onPointerEnter={() => { if (open) return; ensureSrc(i); hoverTo(i, 1); }} onPointerLeave={() => hoverTo(i, 0)} aria-label="Play video" className={`group size-36 rounded-4xl transition-colors duration-300 ease-out ${ open ? "pointer-events-none" : "cursor-pointer" }`} > <span className="flex size-full scale-90 items-center justify-center opacity-0 transition-all duration-300 ease-out group-hover:scale-100 group-hover:opacity-100"> <svg width="32" height="32" viewBox="0 0 24 24" fill="white" aria-hidden="true"> <path d="M8 5.14v13.72a1 1 0 0 0 1.52.85l11-6.86a1 1 0 0 0 0-1.7l-11-6.86A1 1 0 0 0 8 5.14Z" /> </svg> </span> </button> ))} </div> </div> </div> <button type="button" onClick={collapse} aria-label="Close" className={`flex size-10 cursor-pointer items-center justify-center rounded-full bg-neutral-100 text-neutral-500 transition-[opacity,color] duration-300 hover:text-neutral-900 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100 ${ open && !busy ? "opacity-100" : "pointer-events-none opacity-0" }`} > <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"> <path d="M6 6l12 12M18 6L6 18" /> </svg> </button> </div> ); }
Adds the files to
components/uibrik/merge-gallery and installs motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/merge-gallery.Props
MergeGallery- duration
- Type number
- 0.55
- How long the tiles take to merge and split.
- blur
- Type number
- 11
- Peak blur while the tiles merge.
- dim
- Type number
- 0.6
- How dark a tile gets under the pointer.
- loop
- Type boolean
- true
- Loop the open video.
- videos
- Type GalleryVideo[]
- SAMPLE_GALLERY_VIDEOS
- Exactly six { src, poster } pairs, laid out 3 × 2.
- className
- Type string
- –
- Classes for the wrapper.