Subframe
Animation & motionHover image preview list

Objects in practice

A quiet project list selects dimensional still lifes beside a separate image window.

Hover, focus or choose a project.

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 ProjectIndex({ active = true }: { active?: boolean }) {
  const reduced = useReducedMotion()
  const [instant, setInstant] = useState(false)

  const [selected, setSelected] = useState(0)
  const names = ["Soft geometry", "Domestic orbit", "Volumes of air"]
  return (
    <section
      className="sf-project-index"
      data-still={!active || reduced || instant}
      onKeyDownCapture={() => setInstant(true)}
      onPointerDownCapture={() => setInstant(false)}
    >
      <div className="index">
        {names.map((name, i) => (
          <button
            type="button"
            aria-pressed={selected === i}
            disabled={!active}
            onFocus={() => {
              if (active) setSelected(i)
            }}
            onPointerEnter={(e) => {
              if (active && e.pointerType === "mouse") {
                setInstant(false)
                setSelected(i)
              }
            }}
            onClick={() => {
              if (active) setSelected(i)
            }}
            key={name}
          >
            <span>{name}</span>
          </button>
        ))}
      </div>
      <div className="window" aria-hidden="true">
        <div className="reel" style={{ transform: `translateY(-${(selected * 100) / 3}%)` }}>
          {names.map((_, i) => (
            <div className={`still still-${i}`} key={i}>
              <i />
              <b />
              <em />
            </div>
          ))}
        </div>
      </div>
      <style>{`.sf-project-index{position:relative;container-type:inline-size;width:100%;height:100%;min-height:280px;overflow:hidden;isolation:isolate;font-family:Arial,sans-serif;color:#202020;background:#ecebe7;box-sizing:border-box}.sf-project-index *{box-sizing:border-box}.sf-project-index button,.sf-project-index input{font:inherit}.sf-project-index button{cursor:pointer;touch-action:manipulation;min-width:44px;min-height:44px}.sf-project-index button:focus-visible,.sf-project-index input:focus-visible,.sf-project-index [tabindex]:focus-visible{outline:2px solid currentColor;outline-offset:4px}.sf-project-index[data-still=true] *{transition:none!important;animation-play-state:paused!important}@media(prefers-reduced-motion:reduce){.sf-project-index *{transition:none!important;animation:none!important}}.sf-project-index .index{position:absolute;left:7%;width:45%;top:23%;z-index:2}.sf-project-index .index button{width:100%;display:flex;justify-content:space-between;align-items:center;padding:22px 0;background:none;border:0;border-bottom:1px solid #b9b8b1;text-align:left;color:#292a27;font:24px Georgia,serif;letter-spacing:-.6px}.sf-project-index .index button[aria-pressed=true]{color:#934d31}.sf-project-index .index button{transition:color .18s ease}.sf-project-index .index button:disabled{cursor:default}.sf-project-index .window{position:absolute;width:32%;height:68%;right:7%;top:16%;transform:rotate(0deg);box-shadow:0 2px 3px #242b2914,0 12px 24px #242b291c;overflow:hidden;pointer-events:none;z-index:1;opacity:1}.sf-project-index .reel{height:300%;transition:transform .24s cubic-bezier(.2,.85,.25,1)}.sf-project-index .still{height:33.3333%;position:relative;background:#b6aca0;overflow:hidden}.sf-project-index .still i,.sf-project-index .still b,.sf-project-index .still em{position:absolute;display:block}.sf-project-index .still i{width:60%;height:58%;left:20%;top:21%;background:radial-gradient(ellipse at 34% 20%,#ece9d8,transparent 43%),linear-gradient(100deg,#a39b88,#dad6bd 35%,#8b846e);border-radius:45% 50% 37% 40%;box-shadow:2px 3px 2px #2f332c24,9px 17px 12px #2f332c50,inset 1px 1px 1px #ffffff60}.sf-project-index .still-1{background:#9eadb3}.sf-project-index .still-1 i{border-radius:50%;width:70%;height:45%;left:15%;top:34%;background:linear-gradient(140deg,#96968b,#eee7cf,#686f6d)}.sf-project-index .still-1 b{height:50%;width:10%;background:#353c3c;left:45%;top:15%;transform:rotate(-30deg)}.sf-project-index .still-2{background:#ce8b66}.sf-project-index .still-2 i{border-radius:0;clip-path:polygon(50% 0,100% 100%,0 100%);background:linear-gradient(100deg,#f4cfac,#a15136)}@media(max-width:380px){.sf-project-index .window{right:7%;width:32%}.sf-project-index .index button{font-size:21px}}`}</style>
    </section>
  )
}

Where it works well

Let a small studio index remain scannable while showing a rich preview for the focused project.

Reduced motion

Preview changes and keyboard navigation settle immediately under reduced motion; host pause disables selections.

Things to know

Three original CSS still lifes with no navigation or external images. Adapt the selected project state to actual product content.

More examples