Subframe
Components & interactionsEnvelope open reveal

Letter collection

A sealed coastal envelope opens into a spread of photographs.

Open the envelope and browse the photographs.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Download images (3)

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"

function useCalm(active: boolean) {
  const [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)
  }, [])
  return reduced || !active
}

const views = [
  ["/motion/assets/coastal-break.webp", "Breakwater"],
  ["/motion/assets/blue-tide.webp", "High tide"],
  ["/motion/assets/mediterranean-clouds.webp", "After the storm"],
]
export default function LetterCollection({ active = true }: { active?: boolean }) {
  const calm = useCalm(active),
    trigger = useRef<HTMLButtonElement | null>(null),
    previousSelection = useRef<number | null>(null),
    [open, setOpen] = useState(false),
    [selected, setSelected] = useState<number | null>(null),
    [instant, setInstant] = useState(false)
  useEffect(() => {
    if (selected === null && previousSelection.current !== null) trigger.current?.focus()
    previousSelection.current = selected
  }, [selected])
  return (
    <section
      className="sf-letter-collection"
      data-open={open}
      data-calm={calm || instant}
      onKeyDown={(e) => {
        if (e.key === "Escape") {
          setInstant(true)
          if (selected !== null) setSelected(null)
          else setOpen(false)
        }
      }}
      aria-label="Coastal photograph envelope"
    >
      <style>{`.sf-letter-collection{width:100%;height:100%;min-height:280px;position:relative;container-type:size;overflow:hidden;background:#b8cbd0;color:#26313a;font-family:Arial,sans-serif}.sf-letter-collection *{box-sizing:border-box}.sf-letter-collection .lc-envelope{position:absolute;left:50%;top:57%;width:218px;height:144px;transform:translate(-50%,-50%);perspective:800px;z-index:4;transition:transform 420ms cubic-bezier(.22,1,.36,1)}.sf-letter-collection[data-open=true] .lc-envelope{transform:translate(-50%,-50%) translateY(20px) scale(.55)}.sf-letter-collection .lc-base{position:absolute;inset:0;background:#e8e6dd;box-shadow:0 14px 20px #384b5033}.sf-letter-collection .lc-flap{position:absolute;inset:0;z-index:6;transform-origin:top center;transition:transform 320ms cubic-bezier(.22,1,.36,1);background:#f7f6ee;clip-path:polygon(0 0,100% 0,50% 61%);backface-visibility:visible}.sf-letter-collection[data-open=true] .lc-flap{transform:rotateX(180deg);z-index:1}.sf-letter-collection .lc-front{position:absolute;inset:0;background:#f3f0e6;clip-path:polygon(0 0,50% 59%,100% 0,100% 100%,0 100%);z-index:7;pointer-events:none}.sf-letter-collection .lc-front:after{content:'';position:absolute;left:0;right:0;bottom:0;height:90px;background:#fffaf0;clip-path:polygon(0 100%,50% 0,100% 100%)}.sf-letter-collection .lc-seal{position:absolute;left:50%;top:49%;transform:translate(-50%,-50%);width:44px;height:44px;background:#273d3d;color:#f9f4e7;border:2px solid #fff3;border-radius:50%;z-index:8;box-shadow:0 2px 4px #0003;cursor:pointer;font-family:Georgia,serif;font-size:17px;transition:opacity 150ms ease}.sf-letter-collection[data-open=true] .lc-seal{opacity:0;pointer-events:none}.sf-letter-collection .lc-photo{position:absolute;width:min(164px,40cqw);height:min(150px,43cqw);top:57%;left:50%;padding:6px 6px 20px;background:#fffaf0;border:0;box-shadow:0 3px 8px #26323a33;transform-origin:center bottom;z-index:3;cursor:pointer;transition:transform 420ms cubic-bezier(.22,1,.36,1);transition-delay:100ms}.sf-letter-collection[data-open=true] .lc-photo{z-index:12}.sf-letter-collection .lc-photo img{display:block;width:100%;height:100%;object-fit:cover;pointer-events:none}.sf-letter-collection .lc-photo span{position:absolute;bottom:6px;left:7px;font:italic 9px Georgia,serif;color:#26313a}.sf-letter-collection footer{position:absolute;z-index:14;bottom:12px;left:0;right:0;display:flex;justify-content:center}.sf-letter-collection footer button{min-height:44px;padding:0 14px;background:transparent;color:#26313a;border:0;font-size:10px;letter-spacing:.1em;cursor:pointer}.sf-letter-collection button:focus-visible{outline:2px solid #223e3e;outline-offset:4px}.sf-letter-collection .lc-detail{position:absolute;inset:16px;background:#fffbef;z-index:30;padding:10px 10px 36px;box-shadow:0 16px 24px #26313a33}.sf-letter-collection .lc-detail img{display:block;width:100%;height:100%;object-fit:contain}.sf-letter-collection .lc-detail button{position:absolute;bottom:0;left:50%;transform:translateX(-50%);height:40px;min-width:60px;background:transparent;border:0;font:italic 13px Georgia,serif;color:#26313a;cursor:pointer}.sf-letter-collection[data-calm=true] .lc-envelope,.sf-letter-collection[data-calm=true] .lc-photo,.sf-letter-collection[data-calm=true] .lc-flap{transition:none}`}</style>
      {views.map(([image, title], i) => (
        <button
          className="lc-photo"
          key={image}
          tabIndex={open ? 0 : -1}
          inert={selected !== null}
          aria-label={`Open ${title}`}
          style={{
            transform: open
              ? `translate(calc(-50% + ${i - 1} * min(136px,24cqw)),calc(-50% - 9cqh + ${
                  i === 1 ? -34 : 0
                }px)) rotate(${[-9, 3, 10][i]}deg)`
              : `translate(-50%,calc(-50% + ${-i * 2}px))`,
          }}
          onClick={(e) => {
            trigger.current = e.currentTarget
            setSelected(i)
          }}
        >
          <img src={image} alt="" />
          <span>{title}</span>
        </button>
      ))}
      <div className="lc-envelope" inert={selected !== null}>
        <div className="lc-base" />
        <div className="lc-flap" />
        <div className="lc-front" />
        <button
          className="lc-seal"
          aria-label="Open coastal photographs"
          tabIndex={open ? -1 : 0}
          onClick={(e) => {
            setInstant(e.detail === 0)
            setOpen(true)
          }}
        ></button>
      </div>
      <footer inert={selected !== null}>
        <button
          onClick={(e) => {
            setInstant(e.detail === 0)
            setOpen(!open)
            setSelected(null)
          }}
        >
          {open ? "Return to envelope" : "Open photographs"}
        </button>
      </footer>
      {selected !== null && (
        <div className="lc-detail">
          <img src={views[selected][0]} alt={views[selected][1]} />
          <button autoFocus aria-label="Return to photographs" onClick={() => setSelected(null)}>
            Close ×
          </button>
        </div>
      )}
    </section>
  )
}

Where it works well

Turn opening a small curated collection into a deliberate reveal.

Reduced motion

The flap and photographs change pose immediately.

Things to know

Three sample photographs; the envelope and spread are authored transforms rather than paper collision simulation.

More examples