Subframe
Animation & motionExpandable image grid

Small editions

An expandable collection of sculptural art prints keeps its place in a contact sheet.

Expand a print

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 { useEffect, useState } from "react"

function useReducedMotion() {
  const [reduced, setReduced] = useState(true)
  useEffect(() => {
    const query = matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => setReduced(query.matches)
    update()
    query.addEventListener("change", update)
    return () => query.removeEventListener("change", update)
  }, [])
  return reduced
}

export default function ContactSheet({ active = true }: { active?: boolean }) {
  const reduced = useReducedMotion()
  const [instant, setInstant] = useState(false)

  const [selected, setSelected] = useState<number | null>(null)
  const names = ["Balance", "Recess", "Counterform", "Passage"]
  return (
    <section
      className="sf-contact-sheet"
      data-still={!active || reduced || instant}
      onKeyDownCapture={() => setInstant(true)}
      onPointerDownCapture={() => setInstant(false)}
    >
      <div className="editions">
        {names.map((name, i) => (
          <button
            type="button"
            key={name}
            className="print"
            data-selected={selected === i}
            data-muted={selected !== null && selected !== i}
            style={{ left: `${(i % 2) * 50}%`, top: `${Math.floor(i / 2) * 50}%` }}
            aria-label={`${selected === i ? "Close" : "Expand"} ${name}`}
            aria-expanded={selected === i}
            onClick={() => setSelected(selected === i ? null : i)}
          >
            <div className={`work work-${i}`}>
              <i />
              <b />
              <span />
            </div>
            <span className="caption">
              {name}
              <small>{String(i + 1).padStart(2, "0")}</small>
            </span>
          </button>
        ))}
      </div>
      <style>{`.sf-contact-sheet{position:relative;width:100%;height:100%;min-height:280px;overflow:hidden;isolation:isolate;font-family:Arial,sans-serif;color:#202020;background:#deddd9;box-sizing:border-box}.sf-contact-sheet *{box-sizing:border-box}.sf-contact-sheet button,.sf-contact-sheet input{font:inherit}.sf-contact-sheet button{cursor:pointer;touch-action:manipulation;min-width:44px;min-height:44px}.sf-contact-sheet button:focus-visible,.sf-contact-sheet input:focus-visible,.sf-contact-sheet [tabindex]:focus-visible{outline:2px solid currentColor;outline-offset:4px}.sf-contact-sheet[data-still=true] *{transition:none!important;animation-play-state:paused!important}@media(prefers-reduced-motion:reduce){.sf-contact-sheet *{transition:none!important;animation:none!important}}.sf-contact-sheet .editions{position:relative;width:min(82%,410px);height:82%;margin:5% auto}.sf-contact-sheet .print{position:absolute;width:48%;height:47%;padding:7px;border:0;background:#f8f7f3;box-shadow:0 2px 4px #332b1e1a;transition:left .5s cubic-bezier(.2,.8,.2,1),top .5s cubic-bezier(.2,.8,.2,1),width .5s cubic-bezier(.2,.8,.2,1),height .5s cubic-bezier(.2,.8,.2,1),opacity .2s;overflow:hidden;text-align:left}.sf-contact-sheet .print[data-selected=true]{left:9%!important;top:0!important;width:82%;height:100%;z-index:3;box-shadow:0 18px 35px #332b1e30}.sf-contact-sheet .print[data-muted=true]{opacity:.25}.sf-contact-sheet .work{height:calc(100% - 22px);position:relative;overflow:hidden;background:#d7c5b5}.sf-contact-sheet .work i,.sf-contact-sheet .work b,.sf-contact-sheet .work span{position:absolute;display:block}.sf-contact-sheet .work-0 i{width:58%;height:80%;left:20%;top:10%;border-radius:50%;background:linear-gradient(100deg,#805b48,#f0d6b7 37%,#b3876c 67%,#64452f);box-shadow:14px 10px 12px #5b453649}.sf-contact-sheet .work-0 b{width:25%;height:42%;left:37%;top:29%;border-radius:50%;background:linear-gradient(90deg,#543d31,#b0896e);box-shadow:inset 3px 2px 3px #22160980}.sf-contact-sheet .work-1{background:#173e42}.sf-contact-sheet .work-1 i{width:78%;height:66%;left:8%;top:18%;transform:rotate(-20deg);background:repeating-linear-gradient(90deg,#7a9c93 0 8%,#c0cebc 9% 12%,#284b48 13% 17%);box-shadow:12px 18px 13px #051e2280}.sf-contact-sheet .work-2{background:#b44133}.sf-contact-sheet .work-2 i{width:66%;height:75%;left:17%;top:15%;background:linear-gradient(110deg,#f7b2a5,#c75546);border-radius:45% 45% 0 0;box-shadow:8px 12px 12px #631d2444}.sf-contact-sheet .work-2 b{width:25%;height:43%;bottom:10%;left:36%;background:#922f2a;border-radius:50% 50% 0 0}.sf-contact-sheet .work-3{background:#c6d0d6}.sf-contact-sheet .work-3 i{width:72%;height:72%;left:14%;top:14%;border-radius:50%;background:conic-gradient(from 40deg,#ededdf,#8a9ba9,#eff0de,#8a9ba9,#ededdf);box-shadow:6px 12px 15px #4b606a66}.sf-contact-sheet .work-3 b{width:40%;height:40%;left:30%;top:30%;border-radius:50%;background:#c6d0d6;box-shadow:inset 3px 5px 9px #4b606a66}.sf-contact-sheet .caption{display:flex;justify-content:space-between;font:11px Georgia,serif;padding-top:6px}.sf-contact-sheet .caption small{font:8px monospace}`}</style>
    </section>
  )
}

Where it works well

Preview a portfolio image without losing the surrounding collection.

Reduced motion

Live reduced-motion preferences remove interpolation and decorative playback. All content and native controls remain available. Keyboard changes settle immediately.

Things to know

Original demonstration content; adapt labels and state to the host product.

More examples