Subframe
Animation & motionSticky scroll card stack

Folio stack

Three graphic folios pin and recede as the next cover enters the reading area.

Scroll through the folios.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

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 { Fragment, useEffect, useRef, useState } from "react"
const names = ["FORM", "VOID", "FIELD"],
  colors = ["#e33427", "#eeece6", "#1d3bc5"],
  inks = ["#211511", "#1b1b1b", "#f1f0e5"]
export default function FolioStack({ active = true }: { active?: boolean }) {
  const box = useRef<HTMLDivElement>(null),
    draw = useRef<() => void>(() => {})
  const [page, setPage] = useState(0),
    [reduced, setReduced] = useState(true)
  useEffect(() => {
    const q = matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => setReduced(q.matches)
    update()
    q.addEventListener("change", update)
    return () => q.removeEventListener("change", update)
  }, [])
  useEffect(() => {
    const el = box.current
    if (!el) return
    const update = () => {
      const cards = Array.from(el.querySelectorAll<HTMLElement>(".fs-slot")),
        anchors = Array.from(el.querySelectorAll<HTMLElement>(".fs-anchor")),
        h = el.clientHeight
      let selected = 0
      cards.forEach((slot, i) => {
        const cover = slot.firstElementChild as HTMLElement,
          next = cards[i + 1],
          top = next ? anchors[i + 1].offsetTop - el.scrollTop : h
        const depth = next ? Math.max(0, Math.min(1, (h - 30 - top) / (h - 55))) : 0
        cover.style.transform = reduced ? "" : `scale(${1 - depth * 0.09})`
        cover.style.filter = reduced ? "" : `brightness(${1 - depth * 0.2})`
        if (el.scrollTop >= anchors[i].offsetTop - i * 12 - 20) selected = i
      })
      setPage(selected)
    }
    draw.current = update
    const observer = new ResizeObserver(update)
    observer.observe(el)
    update()
    return () => {
      observer.disconnect()
      draw.current = () => {}
    }
  }, [reduced])
  useEffect(() => {
    if ((!active || reduced) && box.current) box.current.scrollTo({ top: box.current.scrollTop, behavior: "instant" })
  }, [active, reduced])
  return (
    <section className="sf-folio-stack">
      <header>
        <span>COLLECTED / 2026</span>
        <span>{String(page + 1).padStart(2, "0")} — 03</span>
      </header>
      <div
        className="fs-scroll"
        ref={box}
        onScroll={() => draw.current()}
        tabIndex={0}
        aria-label="Scroll three exhibition folios"
      >
        {names.map((name, i) => (
          <Fragment key={name}>
            <span className="fs-anchor" aria-hidden="true" />
            <article className="fs-slot" style={{ top: 10 + i * 12, zIndex: i + 1 }}>
              <div className="fs-cover" style={{ background: colors[i], color: inks[i] }}>
                <div className="fs-meta">
                  <span>STUDIES IN SPACE</span>
                  <span>Nº 0{i + 1}</span>
                </div>
                <strong>{name}</strong>
                <svg viewBox="0 0 400 120" aria-hidden="true">
                  {i === 0 ? (
                    <>
                      {Array.from({ length: 10 }, (_, j) => (
                        <path
                          key={j}
                          d={`M${80 + j * 9} 10 L${130 + j * 9} 110 L${280 - j * 5} 110 L${230 - j * 5} 10Z`}
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="1.4"
                        />
                      ))}
                    </>
                  ) : i === 1 ? (
                    <>
                      <rect x="45" y="10" width="310" height="100" fill="currentColor" />
                      <ellipse cx="200" cy="60" rx="106" ry="50" fill={colors[i]} />
                      <ellipse cx="200" cy="60" rx="36" ry="50" fill="currentColor" />
                    </>
                  ) : (
                    <>
                      {Array.from({ length: 22 }, (_, j) => (
                        <path
                          key={j}
                          d={`M${24 + j * 16} 105 Q${200 + (j - 11) * 3} ${-40 + Math.abs(j - 11) * 6} ${
                            375 - j * 8
                          } 105`}
                          stroke="currentColor"
                          fill="none"
                          strokeWidth="1"
                        />
                      ))}
                    </>
                  )}
                </svg>
                <span className="fs-edition">OBJECTS & OBSERVATIONS</span>
              </div>
            </article>
          </Fragment>
        ))}
        <div className="fs-tail" />
      </div>
      <button
        className="fs-next"
        type="button"
        aria-label={page === 2 ? "Return to first folio" : "Next folio"}
        onClick={(e) => {
          const el = box.current
          if (!el) return
          const next = (page + 1) % 3,
            slot = el.querySelectorAll<HTMLElement>(".fs-anchor")[next]
          el.scrollTo({
            top: slot.offsetTop - next * 12 - 10,
            behavior: active && !reduced && e.detail !== 0 ? "smooth" : "instant",
          })
        }}
      >
        ↓
      </button>
      <style>{`.sf-folio-stack{position:relative;box-sizing:border-box;width:100%;height:100%;min-height:280px;overflow:hidden;background:#262a30;color:#d7d9d9;font-family:Arial,sans-serif;container-type:size}.sf-folio-stack *{box-sizing:border-box}.sf-folio-stack header{height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;font:9px monospace;letter-spacing:.07em}.sf-folio-stack .fs-scroll{height:calc(100% - 44px);overflow:auto;overscroll-behavior:contain;scrollbar-width:none;position:relative;padding:0 24px}.sf-folio-stack .fs-scroll::-webkit-scrollbar{display:none}.sf-folio-stack .fs-anchor{display:block;height:0;width:0}.sf-folio-stack .fs-slot{height:calc(100cqh - 100px);min-height:180px;position:sticky;margin-bottom:26px}.sf-folio-stack .fs-cover{height:100%;padding:18px 22px;position:relative;overflow:hidden;transform-origin:50% 0;box-shadow:0 3px 7px #0003}.sf-folio-stack .fs-meta{display:flex;justify-content:space-between;font:8px monospace;letter-spacing:.06em}.sf-folio-stack strong{font:900 clamp(38px,12cqw,74px)/.9 Arial;letter-spacing:-.08em;display:block;margin:14px 0 0}.sf-folio-stack svg{width:70%;height:42%;display:block;position:absolute;right:2%;bottom:12%}.sf-folio-stack .fs-edition{position:absolute;left:22px;bottom:15px;font:7px monospace;letter-spacing:.1em}.sf-folio-stack .fs-tail{height:60px}.sf-folio-stack .fs-next{position:absolute;bottom:10px;right:5px;z-index:5;border:1px solid #ffffff2a;border-radius:50%;width:44px;height:44px;background:#262a30;color:#eee;font:24px Arial;cursor:pointer}.sf-folio-stack :focus-visible{outline:2px solid #fff;outline-offset:-3px}@container(max-width:380px){.sf-folio-stack .fs-scroll{padding:0 17px}.sf-folio-stack .fs-cover{padding:16px}.sf-folio-stack strong{margin-top:19px}.sf-folio-stack .fs-edition{left:16px;font-size:6px}}`}</style>
    </section>
  )
}

Where it works well

Present a short sequence with spatial continuity and native scrolling.

Reduced motion

Sticky positioning remains; scale, dimming and smooth programmatic scrolling are removed.

Things to know

Three authored covers inside a bounded scroll area; not a virtualized list or page scroll controller.

More examples