{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "glass-image-compare", "type": "registry:ui", "title": "Glass Image Compare", "description": "iOS 26 liquid-glass before/after image comparison slider with drag divider, floating labels, and drop-to-load support.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/glass-image-compare.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useMotionValue, useTransform, animate } from \"motion/react\";\n\n/* ── sound ─────────────────────────────────────────────── */\nlet _a: AudioContext, _b: AudioBuffer;\nconst tick = () => {\n if (typeof window === \"undefined\") return;\n if (!_a) {\n _a = new AudioContext();\n _b = _a.createBuffer(1, (_a.sampleRate * 0.003) | 0, _a.sampleRate);\n const d = _b.getChannelData(0);\n for (let i = 0; i < d.length; i++)\n d[i] = (Math.random() * 2 - 1) * (1 - i / d.length) ** 4;\n }\n const s = _a.createBufferSource();\n s.buffer = _b;\n const g = _a.createGain();\n g.gain.value = 0.08;\n s.connect(g).connect(_a.destination);\n s.start();\n};\n\n/* ── theme ─────────────────────────────────────────────── */\nconst GC_CSS = `\n.gc{\n --gc-glass:linear-gradient(180deg,rgba(255,255,255,0.78),rgba(255,255,255,0.62));\n --gc-border:rgba(0,0,0,0.06);\n --gc-shadow:0 0 1px rgba(0,0,0,0.04),0 2px 8px rgba(0,0,0,0.04),inset 0 1px 0 rgba(255,255,255,0.8);\n --gc-dim:rgba(0,0,0,0.35);\n --gc-mid:rgba(0,0,0,0.5);\n --gc-hi:rgba(0,0,0,0.88);\n --gc-divider:rgba(255,255,255,0.92);\n --gc-divider-border:rgba(0,0,0,0.12);\n --gc-divider-shadow:0 0 12px rgba(0,0,0,0.08),0 2px 6px rgba(0,0,0,0.06);\n --gc-knob:rgba(255,255,255,0.95);\n --gc-knob-border:rgba(0,0,0,0.1);\n --gc-knob-shadow:0 1px 8px rgba(0,0,0,0.1),0 2px 4px rgba(0,0,0,0.06);\n --gc-label:rgba(255,255,255,0.82);\n --gc-label-border:rgba(0,0,0,0.06);\n --gc-label-shadow:0 1px 6px rgba(0,0,0,0.05);\n --gc-canvas:rgba(0,0,0,0.03);\n --gc-drop-bg:rgba(0,0,0,0.02);\n --gc-drop-border:rgba(0,0,0,0.1)\n}\n.dark .gc,[data-theme=\"dark\"] .gc{\n --gc-glass:linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02));\n --gc-border:rgba(255,255,255,0.07);\n --gc-shadow:0 1px 3px rgba(0,0,0,0.08),inset 0 1px 0 rgba(255,255,255,0.04);\n --gc-dim:rgba(255,255,255,0.28);\n --gc-mid:rgba(255,255,255,0.5);\n --gc-hi:rgba(255,255,255,0.88);\n --gc-divider:rgba(255,255,255,0.12);\n --gc-divider-border:rgba(255,255,255,0.15);\n --gc-divider-shadow:0 0 12px rgba(0,0,0,0.2),0 2px 6px rgba(0,0,0,0.15);\n --gc-knob:rgba(255,255,255,0.12);\n --gc-knob-border:rgba(255,255,255,0.18);\n --gc-knob-shadow:0 1px 8px rgba(0,0,0,0.15),0 2px 4px rgba(0,0,0,0.1);\n --gc-label:rgba(255,255,255,0.08);\n --gc-label-border:rgba(255,255,255,0.06);\n --gc-label-shadow:0 1px 6px rgba(0,0,0,0.12);\n --gc-canvas:rgba(0,0,0,0.2);\n --gc-drop-bg:rgba(255,255,255,0.02);\n --gc-drop-border:rgba(255,255,255,0.1)\n}`;\n\nconst C = {\n glass: \"var(--gc-glass)\",\n border: \"var(--gc-border)\",\n shadow: \"var(--gc-shadow)\",\n dim: \"var(--gc-dim)\",\n mid: \"var(--gc-mid)\",\n hi: \"var(--gc-hi)\",\n divider: \"var(--gc-divider)\",\n dividerBorder: \"var(--gc-divider-border)\",\n dividerShadow: \"var(--gc-divider-shadow)\",\n knob: \"var(--gc-knob)\",\n knobBorder: \"var(--gc-knob-border)\",\n knobShadow: \"var(--gc-knob-shadow)\",\n label: \"var(--gc-label)\",\n labelBorder: \"var(--gc-label-border)\",\n labelShadow: \"var(--gc-label-shadow)\",\n canvas: \"var(--gc-canvas)\",\n dropBg: \"var(--gc-drop-bg)\",\n dropBorder: \"var(--gc-drop-border)\",\n};\n\n/* ── types ─────────────────────────────────────────────── */\ninterface GlassImageCompareProps {\n before?: string;\n after?: string;\n beforeLabel?: string;\n afterLabel?: string;\n initialPosition?: number;\n sound?: boolean;\n style?: React.CSSProperties;\n onPositionChange?: (position: number) => void;\n}\n\n/* ═══════════════════════════════════════════════════════════\n GlassImageCompare — before/after slider\n ═══════════════════════════════════════════════════════════ */\nfunction GlassImageCompare({\n before: beforeProp,\n after: afterProp,\n beforeLabel = \"Before\",\n afterLabel = \"After\",\n initialPosition = 50,\n sound = true,\n style,\n onPositionChange,\n}: GlassImageCompareProps) {\n const containerRef = React.useRef(null);\n const [beforeSrc, setBeforeSrc] = React.useState(beforeProp ?? \"\");\n const [afterSrc, setAfterSrc] = React.useState(afterProp ?? \"\");\n const [dragging, setDragging] = React.useState(false);\n const [hovered, setHovered] = React.useState(false);\n const position = useMotionValue(initialPosition);\n const clipRight = useTransform(position, (p) => `inset(0 0 0 ${p}%)`);\n const clipLeft = useTransform(position, (p) => `inset(0 ${100 - p}% 0 0)`);\n const dividerX = useTransform(position, (p) => `${p}%`);\n\n React.useEffect(() => {\n if (beforeProp !== undefined) setBeforeSrc(beforeProp);\n }, [beforeProp]);\n\n React.useEffect(() => {\n if (afterProp !== undefined) setAfterSrc(afterProp);\n }, [afterProp]);\n\n const updatePosition = React.useCallback(\n (clientX: number) => {\n const el = containerRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n const pct = ((clientX - rect.left) / rect.width) * 100;\n const clamped = Math.max(2, Math.min(98, pct));\n position.set(clamped);\n onPositionChange?.(clamped);\n },\n [position, onPositionChange],\n );\n\n const handlePointerDown = React.useCallback(\n (e: React.PointerEvent) => {\n e.preventDefault();\n setDragging(true);\n (e.target as HTMLElement).setPointerCapture(e.pointerId);\n updatePosition(e.clientX);\n if (sound) tick();\n },\n [sound, updatePosition],\n );\n\n const handlePointerMove = React.useCallback(\n (e: React.PointerEvent) => {\n if (!dragging) return;\n updatePosition(e.clientX);\n },\n [dragging, updatePosition],\n );\n\n const handlePointerUp = React.useCallback(() => {\n setDragging(false);\n }, []);\n\n /* drop handler for each side */\n const handleDrop = React.useCallback(\n (side: \"before\" | \"after\") => (e: React.DragEvent) => {\n e.preventDefault();\n const file = e.dataTransfer.files[0];\n if (!file?.type.startsWith(\"image/\")) return;\n const r = new FileReader();\n r.onload = () => {\n const url = r.result as string;\n if (side === \"before\") setBeforeSrc(url);\n else setAfterSrc(url);\n if (sound) tick();\n };\n r.readAsDataURL(file);\n },\n [sound],\n );\n\n /* double-click to reset to center */\n const handleDoubleClick = React.useCallback(() => {\n animate(position, 50, { type: \"spring\", stiffness: 300, damping: 30 });\n onPositionChange?.(50);\n if (sound) tick();\n }, [position, sound, onPositionChange]);\n\n const hasImages = beforeSrc && afterSrc;\n\n return (\n <>\n \n \n {/* ── comparison viewport ─────────────────── */}\n setHovered(true)}\n onMouseLeave={() => setHovered(false)}\n style={{\n position: \"relative\",\n borderRadius: 14,\n background: C.canvas,\n border: `1px solid ${C.border}`,\n boxShadow: C.shadow,\n overflow: \"hidden\",\n height: hasImages ? 320 : 200,\n cursor: hasImages\n ? dragging\n ? \"grabbing\"\n : \"ew-resize\"\n : \"default\",\n userSelect: \"none\",\n touchAction: \"none\",\n }}\n >\n {!hasImages ? (\n /* ── empty state: drop zones ─────────── */\n \n \n \n \n \n ) : (\n <>\n {/* before image (full, bottom layer) */}\n \n\n {/* after image (clipped, top layer) */}\n \n\n {/* ── divider line ──────────────── */}\n \n\n {/* ── knob ─────────────────────── */}\n \n \n {/* left arrow */}\n \n \n \n {/* right arrow */}\n \n \n \n \n \n\n {/* ── labels ────────────────────── */}\n \n \n \n )}\n \n\n {/* ── info bar ───────────────────────────── */}\n {hasImages && (\n \n )}\n \n \n );\n}\n\n/* ── drop side (empty state) ────────────────────────── */\nfunction DropSide({\n label,\n onDrop,\n hasSrc,\n}: {\n label: string;\n onDrop: (e: React.DragEvent) => void;\n hasSrc: boolean;\n}) {\n const [over, setOver] = React.useState(false);\n const ref = React.useRef(null);\n\n const handleFileSelect = React.useCallback(\n (files: FileList) => {\n const file = files[0];\n if (!file?.type.startsWith(\"image/\")) return;\n // Simulate a drop event via the onDrop callback\n const fakeEvent = {\n preventDefault: () => {},\n dataTransfer: { files },\n } as unknown as React.DragEvent;\n onDrop(fakeEvent);\n },\n [onDrop],\n );\n\n return (\n {\n e.preventDefault();\n setOver(true);\n }}\n onDragLeave={() => setOver(false)}\n onDrop={(e) => {\n setOver(false);\n onDrop(e);\n }}\n onClick={() => ref.current?.click()}\n style={{\n flex: 1,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 8,\n cursor: \"pointer\",\n background: over ? C.dropBg : \"transparent\",\n transition: \"background 150ms\",\n borderRadius: 4,\n }}\n >\n {\n if (e.target.files?.length) handleFileSelect(e.target.files);\n e.target.value = \"\";\n }}\n style={{ display: \"none\" }}\n />\n
\n \n \n \n \n \n
\n \n {label}\n \n
Drop or click
\n \n );\n}\n\n/* ── floating label ─────────────────────────────────── */\nfunction Label({\n text,\n side,\n visible,\n}: {\n text: string;\n side: \"left\" | \"right\";\n visible: boolean;\n}) {\n return (\n \n {text}\n \n );\n}\n\n/* ── info bar ───────────────────────────────────────── */\nfunction InfoBar({\n position,\n beforeLabel,\n afterLabel,\n}: {\n position: ReturnType>;\n beforeLabel: string;\n afterLabel: string;\n}) {\n const [pct, setPct] = React.useState(50);\n\n React.useEffect(() => {\n const unsub = position.on(\"change\", (v) => setPct(Math.round(v)));\n return unsub;\n }, [position]);\n\n return (\n \n \n {beforeLabel}\n \n\n {/* mini bar */}\n \n \n \n\n \n {afterLabel}\n \n\n \n {pct}%\n \n \n );\n}\n\nexport { GlassImageCompare };\nexport type { GlassImageCompareProps };\n", "type": "registry:ui", "target": "components/ruixen/glass-image-compare.tsx" } ] }