Skip to content
Skip to Content
DocsHover Slider
Hover Slider

Hover Slider

An editorial list reveals a curved image stack with elastic image transitions.

Hover or focus a project row to reveal its image.

Component preview
Hover Slider

Install using CLI

npx shadcn@latest add "https://www.obsidianui.dev/r/interactive-hover-slider.json"

Usage

Code
import { InteractiveHoverSlider } from "@/components/block/interactive-hover-slider"; export default function Demo() { return <InteractiveHoverSlider className="h-[400px]" />; }

Install Manually

1

Install dependencies

npm install clsx gsap tailwind-merge three
2

Copy the source code

Copy into components/block/interactive-hover-slider.jsx

"use client"; import { useEffect, useRef, useState, useCallback } from"react"; import * as THREE from"three"; import gsap from"gsap"; import { WebGLSurface, useEffectReducedMotion } from "@/lib/effects/shared/webgl-surface"; const vertexShader = /* glsl */` uniform vec2 uVelocity; uniform vec2 uViewport; uniform float uCurvature; varying vec2 vUv; float circularArc(float d) { float maxAngle = 1.15; float theta = clamp(d, 0.0, 1.0) * maxAngle; return (1.0 - cos(theta)) / (1.0 - cos(maxAngle)); } void main() { vUv = uv; vec4 worldPos = modelMatrix * vec4(position, 1.0); float nx = worldPos.x / uViewport.x; float ny = worldPos.y / uViewport.y; float cx = clamp(nx, -1.0, 1.0); float cy = clamp(ny, -1.0, 1.0); // distance from center (0 center → 1 edges) float distY = abs(cy); float distX = abs(cx); // circular-arc falloff: keeps the same edge max, but reads as a real curve float curveY = circularArc(distY); float curveX = circularArc(distX); // GSAP controls uCurvature, so the curve can ease smoothly back to plane. float edgeLift = curveY * uCurvature + curveX * (uCurvature * 0.1); // final Z (ONLY edges move forward, center stays stable) float finalZOffset = edgeLift; // perspective float focalLength = max(uViewport.y * 2.2, 900.0); float perspective = focalLength / (focalLength - finalZOffset); vec3 finalPos = worldPos.xyz; finalPos.xy *= perspective; finalPos.z += finalZOffset; gl_Position = projectionMatrix * viewMatrix * vec4(finalPos, 1.0); } `; const fragmentShader = /* glsl */` uniform sampler2D uTexture; uniform vec2 uPlaneSize; uniform vec2 uImageSize; uniform float uAlpha; uniform float uZoom; varying vec2 vUv; vec2 coverUv(vec2 uv, vec2 planeSize, vec2 imageSize) { float planeRatio = planeSize.x / planeSize.y; float imageRatio = imageSize.x / imageSize.y; vec2 scale = vec2(1.0); if (planeRatio > imageRatio) { scale.y = imageRatio / planeRatio; } else { scale.x = planeRatio / imageRatio; } uv = (uv - 0.5) * scale + 0.5; return (uv - 0.5) / uZoom + 0.5; } void main() { vec2 uv = coverUv(vUv, uPlaneSize, uImageSize); if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard; vec4 tex = texture2D(uTexture, uv); gl_FragColor = vec4(tex.rgb, tex.a * uAlpha); } `; const clamp = (v, mn, mx) => Math.min(Math.max(v, mn), mx); const lerp = (a, b, t) => a + (b - a) * t; function HoverSliderScene({ items, compact }) { const reducedMotion = useEffectReducedMotion(); const mountRef = useRef(null); const glRef = useRef(null); const isDesktopRef = useRef(false); const stateRef = useRef({ activeIndex: 0, hovering: false, hasClicked: false }); const [highlightedIndex, setHighlightedIndex] = useState(null); useEffect(() => { if (!items.length) return; const mount = mountRef.current; if (!mount) return; let disposed = false; let repaint = () => {}; let W = Math.max(1, mount.clientWidth); let H = Math.max(1, mount.clientHeight); const CARD_ASPECT = 1.7; const GAP = 14; const VISIBLE = 7; const HALF = 3; const getCardH = () => Math.round(H * (compact ? 0.39 : W < 768 ? 0.34 : 0.46)); const getCardW = () => { const cardW = getCardH() * CARD_ASPECT; return Math.round(W < 768 ? Math.min(cardW, W * 0.88) : cardW); }; const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setClearColor(0x000000, 0); Object.assign(renderer.domElement.style, { position: "absolute", top:"0", left:"0", right:"0", bottom: W < 768 ?"auto" :"0", width:"100%", height: "100%", zIndex:"15", pointerEvents:"none", }); mount.appendChild(renderer.domElement); const scene = new THREE.Scene(); const camera = new THREE.OrthographicCamera(); const updateCamera = () => { camera.left = -W / 2; camera.right = W / 2; camera.top = H / 2; camera.bottom = -H / 2; camera.near = -2000; camera.far = 2000; camera.updateProjectionMatrix(); }; camera.position.z = 1000; updateCamera(); renderer.setSize(W, H, false); const loader = new THREE.TextureLoader(); const texCache = {}; const getTexture = (src) => { if (texCache[src]) return texCache[src]; const tex = loader.load(src, (t) => { if (disposed) { t.dispose(); return; } t.colorSpace = THREE.SRGBColorSpace; t.minFilter = THREE.LinearFilter; t.magFilter = THREE.LinearFilter; t.userData.iw = t.image?.width || 1; t.userData.ih = t.image?.height || 1; repaint(); }); tex.userData.iw = 1; tex.userData.ih = 1; texCache[src] = tex; return tex; }; items.forEach(item => getTexture(item.img)); const syncImageSize = (mesh) => { const t = mesh.material.uniforms.uTexture.value; if (!t?.image) return; mesh.material.uniforms.uImageSize.value.set( t.image.width || t.userData.iw || 1, t.image.height || t.userData.ih || 1, ); }; const geo = new THREE.PlaneGeometry(1, 1, 80, 80); let CW = getCardW(), CH = getCardH(); const makeMat = (tex) => new THREE.ShaderMaterial({ uniforms: { uTexture: { value: tex }, uPlaneSize: { value: new THREE.Vector2(CW, CH) }, uImageSize: { value: new THREE.Vector2(tex.userData.iw, tex.userData.ih) }, uVelocity: { value: new THREE.Vector2(0, 0) }, uAlpha: { value: 0 }, uZoom: { value: 1.06 }, uViewport: { value: new THREE.Vector2(W / 2, H / 2) }, uCurvature: { value: 0 }, }, vertexShader, fragmentShader, transparent: true, depthWrite: false, side: THREE.DoubleSide, }); const firstTex = getTexture(items[0].img); const meshes = Array.from({ length: VISIBLE }, (_, i) => { const mesh = new THREE.Mesh(geo, makeMat(firstTex)); mesh.renderOrder = i; scene.add(mesh); return mesh; }); // ── Curve / idle state ──────────────────────────────────────────────────── const curveAnim = { value: 0, zoom: 1.06 }; const anim = { alpha: 0 }; const ACTIVE_CURVE = 400; const SOFT_CURVE = 80; let floatIdx = 0; let prevFloat = 0; const vel = new THREE.Vector2(0, 0); let raf = 0; const getCurveForTravel = (targetIdx) => { const travel = Math.abs(targetIdx - floatIdx); const p = clamp((travel - 0.35) / 3.5, 0, 1); const eased = p * p * (3 - 2 * p); return lerp(SOFT_CURVE, ACTIVE_CURVE, eased); }; const releaseCurve = (targetIdx = stateRef.current.activeIndex) => { if (reducedMotion) { curveAnim.value = 0; return; } const peakCurve = getCurveForTravel(targetIdx); gsap.killTweensOf(curveAnim); curveAnim.zoom = 1.06; gsap .timeline() .to(curveAnim, { value: peakCurve, zoom: 1.06, duration: 0.12, ease:"power2.out", }) .to(curveAnim, { value: 0, zoom: 1.06, duration: 1.25, ease:"power2.inOut", }); }; const show = (targetIdx) => { if (reducedMotion) { anim.alpha = 1; curveAnim.value = 0; return; } gsap.killTweensOf(anim); gsap.to(anim, { alpha: 1, duration: reducedMotion ? 0 : 0.45, ease:"power3.out" }); releaseCurve(targetIdx); }; const hide = () => { if (reducedMotion) { anim.alpha = 0; curveAnim.value = 0; return; } gsap.killTweensOf(anim); gsap.killTweensOf(curveAnim); gsap.to(anim, { alpha: 0, duration: reducedMotion ? 0 : 0.35, ease:"power2.out" }); gsap.to(curveAnim, { value: 0, zoom: 1.06, duration: reducedMotion ? 0 : 0.55, ease:"power2.inOut" }); }; const onRowChange = (targetIdx) => { releaseCurve(targetIdx); }; const onResize = () => { const wasDesktop = isDesktopRef.current; W = Math.max(1, mount.clientWidth); H = Math.max(1, mount.clientHeight); isDesktopRef.current = W >= 768; Object.assign(renderer.domElement.style, { position: "absolute", bottom: W < 768 ?"auto" :"0", height: "100%", }); renderer.setSize(W, H, false); updateCamera(); CW = getCardW(); CH = getCardH(); meshes.forEach(m => { // width is always CW — never scaled m.material.uniforms.uPlaneSize.value.set(CW, CH); m.material.uniforms.uViewport.value.set(W / 2, H / 2); }); if (!wasDesktop && isDesktopRef.current) { stateRef.current.hasClicked = true; stateRef.current.hovering = true; stateRef.current.activeIndex = 0; setHighlightedIndex(0); show(0); } }; const observer = new ResizeObserver(() => { onResize(); repaint(); }); observer.observe(mount); onResize(); { stateRef.current.hasClicked = true; stateRef.current.hovering = true; setHighlightedIndex(0); show(0); } const tick = () => { if (!reducedMotion) raf = requestAnimationFrame(tick); const targetIdx = stateRef.current.activeIndex; // distance between current and target const diff = targetIdx - floatIdx; const dist = Math.abs(diff); // 🎯 inverse lerp (more distance = slower catch-up) const t = clamp(0.18 - dist * 0.06, 0.05, 0.18); // smooth scrolling floatIdx = reducedMotion ? targetIdx : floatIdx + diff * t; const delta = floatIdx - prevFloat; // stronger + faster velocity response vel.y = lerp(vel.y, delta * 60, 0.16); vel.x = lerp(vel.x, 0, 0.14); prevFloat = floatIdx; const centreInt = Math.round(floatIdx); const drift = floatIdx - centreInt; for (let i = 0; i < VISIBLE; i++) { const offset = i - HALF; const itemIdx = ((centreInt + offset) % items.length + items.length) % items.length; const posY = (-offset + drift) * (CH + GAP); const dist = Math.abs(offset - drift); // ── Width is ALWAYS CW — no scaling on X ───────────────────────────── // Height still scales so non-centre cards recede slightly. const scaleH = Math.max(0.76, 1.0 - dist * 0.06); const sw = CW; // fixed width for every plane const sh = CH * scaleH; // height varies with distance const baseOpacity = 0.88; // 👈 controls center opacity (tweak this) const opacity = Math.max(0, baseOpacity - dist * 0.22) * anim.alpha; const wantTex = getTexture(items[itemIdx].img); if (meshes[i].material.uniforms.uTexture.value !== wantTex) { meshes[i].material.uniforms.uTexture.value = wantTex; } syncImageSize(meshes[i]); meshes[i].position.set(compact ? W * 0.24 : 0, posY, i); meshes[i].scale.set(sw, sh, 1); meshes[i].rotation.z = 0; meshes[i].material.uniforms.uVelocity.value.set(vel.x, vel.y * 0.28); meshes[i].material.uniforms.uAlpha.value = opacity; meshes[i].material.uniforms.uZoom.value = curveAnim.zoom - clamp(1.0 - dist, 0, 1) * 0.04; // uPlaneSize reflects actual rendered size (sw × sh) for correct cover-UV math meshes[i].material.uniforms.uPlaneSize.value.set(sw, sh); meshes[i].material.uniforms.uCurvature.value = curveAnim.value; meshes[i].material.uniforms.uViewport.value.set(W / 2, H / 2); } renderer.render(scene, camera); }; repaint = () => { if (reducedMotion && !disposed) tick(); }; tick(); glRef.current = { setActive: (i) => { stateRef.current.activeIndex = i; repaint(); }, show, hide, onRowChange, }; return () => { disposed = true; cancelAnimationFrame(raf); observer.disconnect(); gsap.killTweensOf(anim); gsap.killTweensOf(curveAnim); geo.dispose(); meshes.forEach(m => m.material.dispose()); Object.values(texCache).forEach(t => t.dispose()); renderer.dispose(); renderer.domElement.remove(); glRef.current = null; }; }, [items, reducedMotion, compact]); const onEnter = useCallback((index) => { if (isDesktopRef.current) { const wasHovering = stateRef.current.hovering; stateRef.current.hasClicked = true; stateRef.current.hovering = true; stateRef.current.activeIndex = index; setHighlightedIndex(index); glRef.current?.setActive(index); if (!wasHovering) { glRef.current?.show(index); } else { glRef.current?.onRowChange(index); } return; } if (!stateRef.current.hasClicked) { setHighlightedIndex(index); return; } const wasHovering = stateRef.current.hovering; stateRef.current.hovering = true; stateRef.current.activeIndex = index; setHighlightedIndex(index); glRef.current?.setActive(index); if (!wasHovering) { glRef.current?.show(index); } else { glRef.current?.onRowChange(index); } }, []); const onLeave = useCallback((event) => { if (event.pointerType ==="touch") return; if (stateRef.current.hasClicked) return; stateRef.current.hovering = false; setHighlightedIndex(null); glRef.current?.hide(); }, []); const activateRow = useCallback((index) => { const wasHovering = stateRef.current.hovering; stateRef.current.hasClicked = true; stateRef.current.hovering = true; stateRef.current.activeIndex = index; setHighlightedIndex(index); glRef.current?.setActive(index); if (!wasHovering) { glRef.current?.show(index); } else { glRef.current?.onRowChange(index); } }, []); return ( <section ref={mountRef} onPointerLeave={onLeave} className={`relative isolate h-full w-full overflow-hidden bg-[#f0ede6] text-[#1e1c18] ${compact ? "px-3 py-3" : "px-4 py-5"}`} style={{ cursor:"crosshair" }} > <header className="relative z-20 flex items-start max-sm:px-2 justify-between text-[11px]" style={{ color:"rgba(30,28,24,0.6)" }} > <div>ObsidianUI</div> {!compact && <nav className="absolute left-1/2 top-0 flex -translate-x-1/2 gap-3 max-sm:gap-5"> <span>Featured Works,</span> <span>Archive</span> <span>About</span> </nav>} {!compact && <div className="flex items-center gap-2 max-sm:hidden"> <span className="inline-block h-2 w-2 bg-[#e63000]" /> <span>Selected work</span> </div>} </header> <div className={`relative z-20 overflow-x-auto pb-4 ${compact ? "mt-4 w-1/2" : "mt-10 w-full"}`}> <div className={`grid gap-2 text-[10px] uppercase tracking-widest ${compact ? "pb-2" : "pb-4"}`} style={{ gridTemplateColumns:compact ? "20px minmax(0,1fr)" : "30px minmax(68px,1fr) 38px", color:"rgba(30,28,24,0.6)", borderBottom:"1px solid rgba(30,28,24,0.06)", }} > <div>ID</div> <div>Title</div> {!compact && <div>Year</div>} </div> {items.map((item, index) => { const active = highlightedIndex === index; return ( <button key={item.id} onPointerEnter={() => onEnter(index)} onPointerDown={() => activateRow(index)} onClick={() => activateRow(index)} type="button" aria-label={`Project ${item.id}: ${item.title}, ${item.year}`} aria-pressed={active} onFocus={() => activateRow(index)} className="w-full border-0 bg-transparent text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#e63000]" style={{ display:"grid", gridTemplateColumns:compact ? "20px minmax(0,1fr)" : "30px minmax(68px,1fr) 38px", gap:"8px", padding:compact ? "5px 0" : "7px 0", borderBottom:"1px solid rgba(30,28,24,0.06)", cursor:"crosshair", transition:"color 0.15s", color: active ?"#e63000" :"rgba(30,28,24,0.6)", }} > <span style={{ fontSize: 11, letterSpacing:"0.03em" }}>{item.id}</span> <span style={{ fontSize: compact ? 10 : 12, fontWeight: 500 }}>{item.title}</span> {!compact && <span style={{ fontSize: 11 }}>{item.year}</span>} </button> ); })} </div> </section> ); } const defaultItems = [ { id: "01", title: "Soft Forms", focus: "Visual study", year: "2026", img: "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/interactive-hover-slider/interactive-hover-slider-img01.webp" }, { id: "02", title: "Botanical", focus: "Art direction", year: "2026", img: "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/interactive-hover-slider/interactive-hover-slider-img02.webp" }, { id: "03", title: "Afterlight", focus: "Identity", year: "2026", img: "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/interactive-hover-slider/interactive-hover-slider-img03.webp" }, { id: "04", title: "Glasswork", focus: "Materials", year: "2026", img: "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/interactive-hover-slider/interactive-hover-slider-img04.png" }, { id: "05", title: "Motion Study", focus: "Experiment", year: "2026", img: "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/interactive-hover-slider/interactive-hover-slider-img05.png" }, ]; /** @param {{ items?: { id: string, title: string, focus: string, year: string, img: string }[], compact?: boolean, className?: string, style?: import("react").CSSProperties }} props */ export function InteractiveHoverSlider({ items = defaultItems, compact = false, className, style } = {}) { return <WebGLSurface className={className} style={style} imageSrc={items[0]?.img} label="ObsidianUI project image gallery"> {items.length > 0 && <HoverSliderScene items={items} compact={compact} />} </WebGLSurface>; }
3

Add the supporting file

Copy into lib/effects/shared/webgl-surface.jsx

"use client"; import { Component, useSyncExternalStore } from "react"; import { cn } from "@/lib/utils"; const subscribeMotion = (notify) => { const query = window.matchMedia("(prefers-reduced-motion: reduce)"); query.addEventListener("change", notify); return () => query.removeEventListener("change", notify); }; export function useEffectReducedMotion() { return useSyncExternalStore(subscribeMotion, () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, () => true); } let webglAvailable; function supportsWebGL() { if (webglAvailable !== undefined) return webglAvailable; try { const canvas = document.createElement("canvas"); const context = canvas.getContext("webgl2"); webglAvailable = Boolean(context); context?.getExtension("WEBGL_lose_context")?.loseContext(); } catch { webglAvailable = false; } return webglAvailable; } const subscribeAvailability = () => () => {}; class SurfaceBoundary extends Component { state = { failed: false }; static getDerivedStateFromError() { return { failed: true }; } render() { return this.state.failed ? this.props.fallback : this.props.children; } } /** @param {{ children?: import("react").ReactNode, className?: string, style?: import("react").CSSProperties, imageSrc?: string, label?: string }} props */ export function WebGLSurface({ children, className, style, imageSrc, label = "ObsidianUI visual effect" }) { const supported = useSyncExternalStore(subscribeAvailability, supportsWebGL, () => false); const fallback = <div role="img" aria-label={label} className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: imageSrc ? `url(${JSON.stringify(imageSrc)})` : undefined }} />; return ( <div className={cn("relative isolate h-[28rem] w-full overflow-hidden bg-black", className)} style={{ containerType: "size", ...style }}> {fallback} {supported && <SurfaceBoundary fallback={fallback}>{children}</SurfaceBoundary>} </div> ); }
4

Add the supporting file

Copy into lib/utils.ts

import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }

Default demo media loads from ObsidianUI. Replace these URLs with your own assets for offline use.

Preview behavior

Hover or focus a project row to reveal its image. The effect stays inside its container and respects reduced motion preferences.

Last updated on