Subframe
Animation & motionFanned card carousel

Specimen fan

Botanical plates fan around one brass binding, bringing the selected specimen upright for inspection.

Drag or select a specimen. Use the arrows, Left, Right, Home and End to browse.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Download images (4)

Save these files in your static assets folder at /motion/assets/, or update their paths in the component.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 *
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

"use client"

import { useEffect, useRef, useState } from "react"

const specimens = [
  ["/motion/assets/botanical-cabbage-rose.webp", "Cabbage rose"],
  ["/motion/assets/botanical-iris.webp", "Gold-banded iris"],
  ["/motion/assets/botanical-turban-lily.webp", "Turban lily"],
  ["/motion/assets/botanical-crocus.webp", "Crocus"],
]
const clamp = (n: number) => Math.max(0, Math.min(specimens.length - 1, n))

export default function RopeGallery({ active = true }: { active?: boolean }) {
  const root = useRef<HTMLElement>(null),
    position = useRef(1),
    target = useRef(1),
    velocity = useRef(0),
    raf = useRef(0)
  const draw = useRef(() => {}),
    run = useRef(() => {})
  const drag = useRef<{ x: number; start: number } | null>(null),
    moved = useRef(false)
  const [selected, setSelected] = useState(1),
    [reduced, setReduced] = useState(true)
  useEffect(() => {
    const media = matchMedia("(prefers-reduced-motion: reduce)")
    const sync = () => setReduced(media.matches)
    sync()
    media.addEventListener("change", sync)
    return () => media.removeEventListener("change", sync)
  }, [])
  useEffect(() => {
    const el = root.current
    if (!el) return
    const cards = Array.from(el.querySelectorAll<HTMLElement>(".sf-plate"))
    let last = 0
    const paint = () => {
      const width = Math.min(152, el.clientWidth * 0.27),
        height = Math.min(244, el.clientHeight - 112)
      const spread = Math.min(27, ((el.clientWidth - width) / height) * 14)
      cards.forEach((card, i) => {
        const distance = i - position.current
        card.style.width = `${width}px`
        card.style.height = `${height}px`
        card.style.transform = `translateX(-50%) rotate(${distance * spread}deg)`
        card.style.zIndex = String(i === Math.round(position.current) ? 30 : 20 - Math.ceil(Math.abs(distance) * 4))
      })
    }
    const tick = (time: number) => {
      raf.current = 0
      const dt = Math.min((time - (last || time - 16)) / 1000, 0.032)
      last = time
      velocity.current += ((target.current - position.current) * 210 - velocity.current * 26) * dt
      position.current += velocity.current * dt
      paint()
      if (Math.abs(target.current - position.current) > 0.001 || Math.abs(velocity.current) > 0.005)
        raf.current = requestAnimationFrame(tick)
      else {
        position.current = target.current
        velocity.current = 0
        paint()
      }
    }
    const start = () => {
      cancelAnimationFrame(raf.current)
      last = 0
      if (reduced) {
        position.current = target.current
        velocity.current = 0
        paint()
      } else if (active && !document.hidden) raf.current = requestAnimationFrame(tick)
    }
    draw.current = paint
    run.current = start
    const ro = new ResizeObserver(paint)
    ro.observe(el)
    paint()
    const visibility = () => (document.hidden ? cancelAnimationFrame(raf.current) : start())
    document.addEventListener("visibilitychange", visibility)
    if (active) start()
    return () => {
      cancelAnimationFrame(raf.current)
      ro.disconnect()
      document.removeEventListener("visibilitychange", visibility)
    }
  }, [active, reduced])
  const choose = (value: number, instant = false) => {
    target.current = clamp(value)
    setSelected(Math.round(target.current))
    if (instant || reduced) {
      cancelAnimationFrame(raf.current)
      position.current = target.current
      velocity.current = 0
      draw.current()
    } else run.current()
  }
  return (
    <section
      ref={root}
      className="sf-rope-gallery"
      aria-label="Botanical specimen fan"
      onKeyDown={(event) => {
        const next =
          event.key === "ArrowRight"
            ? target.current + 1
            : event.key === "ArrowLeft"
            ? target.current - 1
            : event.key === "Home"
            ? 0
            : event.key === "End"
            ? 3
            : null
        if (next === null) return
        event.preventDefault()
        choose(next, true)
        root.current?.querySelectorAll<HTMLButtonElement>(".sf-plate")[Math.round(clamp(next))]?.focus()
      }}
    >
      <style>{`.sf-rope-gallery{position:relative;width:100%;height:100%;min-height:280px;overflow:hidden;background:radial-gradient(ellipse at 50% 25%,#34584d,#183b32 85%);color:#e9eadc;font-family:Arial,sans-serif;isolation:isolate}.sf-rope-gallery *{box-sizing:border-box}.sf-rope-gallery:before{content:'';position:absolute;inset:0;opacity:.12;pointer-events:none;background-image:repeating-linear-gradient(0deg,transparent 0 2px,#d5e5d20c 2px 3px)}.sf-rope-gallery .sf-plate{position:absolute;left:50%;bottom:82px;transform-origin:50% calc(100% - 17px);border:0;border-radius:4px 4px 11px 11px;padding:8px 8px 37px;background:#eae8dc;color:#28382c;box-shadow:0 1px 0 #fffffff0 inset,0 -1px 0 #6c68557a inset,1px 3px 3px #09170e30,4px 9px 16px #08221950;cursor:grab;touch-action:pan-y;user-select:none}.sf-rope-gallery .sf-plate:active{cursor:grabbing}.sf-rope-gallery .sf-plate:before{content:'';position:absolute;inset:0;border-radius:inherit;pointer-events:none;background:linear-gradient(115deg,#fff5,transparent 40%,#6d665211);z-index:1}.sf-rope-gallery .sf-plate img{width:100%;height:100%;display:block;object-fit:contain;mix-blend-mode:multiply;pointer-events:none}.sf-rope-gallery .sf-plate span{position:absolute;bottom:30px;left:0;width:100%;font:italic 14px Georgia,serif;text-align:center}.sf-rope-gallery .sf-plate:focus-visible{outline:2px solid #f2cf74;outline-offset:4px}.sf-rope-gallery .sf-pivot{position:absolute;z-index:40;left:50%;bottom:90px;transform:translateX(-50%);height:18px;width:18px;border:1px solid #a88847;border-radius:50%;background:conic-gradient(from 30deg,#d5ba77,#8b6c32,#ead597,#ae8d4b,#e6ce91,#8b6c32,#d5ba77);box-shadow:0 1px 1px #fff9 inset,0 2px 2px #16251bcc,0 0 0 3px #574a261f;pointer-events:none}.sf-rope-gallery .sf-pivot:after{content:'';position:absolute;inset:4px;border:1px solid #705d38;border-radius:50%;box-shadow:0 1px 1px #fff8}.sf-rope-gallery footer{position:absolute;left:24px;right:24px;bottom:10px;display:flex;height:44px;align-items:center;justify-content:space-between;font-size:11px}.sf-rope-gallery .sf-count{font-variant-numeric:tabular-nums;font-size:10px}.sf-rope-gallery .sf-pagination{display:flex;align-items:center;gap:8px;font-variant-numeric:tabular-nums;font-size:10px}.sf-rope-gallery footer button{width:44px;height:44px;border:0;color:inherit;background:transparent;cursor:pointer;font:21px Arial;border-radius:50%;transition:background-color 150ms,transform 150ms}.sf-rope-gallery footer button:disabled{opacity:.3;cursor:default}.sf-rope-gallery footer button:active{transform:scale(.96)}.sf-rope-gallery footer button:focus-visible{outline:2px solid #f2cf74}@media(hover:hover){.sf-rope-gallery footer button:hover:not(:disabled){background:#ffffff10}}`}</style>
      {specimens.map(([image, title], index) => (
        <button
          key={image}
          className="sf-plate"
          tabIndex={selected === index ? 0 : -1}
          aria-label={`View ${title}`}
          aria-pressed={selected === index}
          onPointerDown={(event) => {
            if (event.button !== 0) return
            cancelAnimationFrame(raf.current)
            velocity.current = 0
            moved.current = false
            drag.current = { x: event.clientX, start: position.current }
            event.currentTarget.setPointerCapture(event.pointerId)
          }}
          onPointerMove={(event) => {
            if (!drag.current || !root.current) return
            const dx =
              ((event.clientX - drag.current.x) * root.current.clientWidth) / root.current.getBoundingClientRect().width
            if (Math.abs(dx) > 4) moved.current = true
            if (!moved.current) return
            position.current = clamp(drag.current.start - dx / 95)
            draw.current()
            setSelected(Math.round(position.current))
          }}
          onPointerUp={(event) => {
            if (!drag.current) return
            drag.current = null
            if (event.currentTarget.hasPointerCapture(event.pointerId))
              event.currentTarget.releasePointerCapture(event.pointerId)
            if (moved.current) choose(Math.round(position.current))
          }}
          onPointerCancel={() => {
            drag.current = null
            choose(Math.round(position.current))
          }}
          onLostPointerCapture={() => {
            if (!drag.current) return
            drag.current = null
            choose(Math.round(position.current))
          }}
          onClick={(event) => {
            if (!moved.current || event.detail === 0) choose(index, event.detail === 0)
          }}
        >
          <img src={image} alt="" draggable={false} />
          <span>{title}</span>
        </button>
      ))}
      <span className="sf-pivot" aria-hidden="true" />
      <footer>
        <span className="sf-count" aria-live="polite">
          {String(selected + 1).padStart(2, "0")} / 04
        </span>
        <div className="sf-pagination">
          <button
            aria-label="Previous specimen"
            disabled={selected === 0}
            onClick={(event) => choose(target.current - 1, event.detail === 0)}
          >
            ←
          </button>
          <button
            aria-label="Next specimen"
            disabled={selected === 3}
            onClick={(event) => choose(target.current + 1, event.detail === 0)}
          >
            →
          </button>
        </div>
      </footer>
    </section>
  )
}

Where it works well

Give a small visual collection a tactile, direct-manipulation browsing surface.

Reduced motion

Selection changes are immediate; direct dragging remains available. Host pause freezes spring interpolation.

Things to know

Four bounded specimen plates with a shared pivot; the cards stay bound and do not detach or open a lightbox.

More examples