The changing frame
A moving aperture reframes a woodblock print while the artwork remains completely still.

Select a print format
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Download images (1)
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 { useId, useState } from "react"
const formats = [
{ label: "4:5", name: "Portrait", width: 198.4, height: 248, left: 203 },
{ label: "1:1", name: "Square", width: 250, height: 250, left: 155 },
{ label: "16:9", name: "Wide", width: 396, height: 222.75, left: 12 },
]
export default function FrameCrop({ active = true }: { active?: boolean }) {
const id = useId()
const [selected, setSelected] = useState(2)
const format = formats[selected]
const x = format.left
const y = (278 - format.height) / 2
return (
<section className="sf-frame-crop" data-active={active} aria-label="Art print format preview">
<div className="fc-aperture-stage">
<img
className="fc-image-context"
src="/motion/assets/fuji-storm.webp"
alt=""
aria-hidden="true"
draggable={false}
/>
<div className="fc-aperture" style={{ clipPath: `inset(${y}px ${420 - x - format.width}px ${y}px ${x}px)` }}>
<img
src="/motion/assets/fuji-storm.webp"
alt="Snow-capped Mount Fuji above lightning and clouds in a Japanese woodblock print"
draggable={false}
/>
</div>
<div
className="fc-crop-edge"
style={{ transform: `translate(${x}px, ${y}px)`, width: format.width, height: format.height }}
aria-hidden="true"
>
<i />
<i />
<i />
<i />
</div>
</div>
<fieldset className="fc-formats">
<legend>Image format</legend>
{formats.map((item, index) => (
<label key={item.label} className="fc-option">
<input
type="radio"
name={id}
value={item.label}
checked={selected === index}
onChange={() => setSelected(index)}
aria-label={`${item.name}, ${item.label}`}
/>
<span className="fc-format-content">
<span
className="fc-format-icon"
style={{
width: index === 2 ? 20 : index === 1 ? 15 : 12,
height: index === 0 ? 16 : index === 1 ? 15 : 12,
}}
aria-hidden="true"
/>
{item.label}
</span>
</label>
))}
</fieldset>
<style>{`
.sf-frame-crop{position:relative;width:100%;height:100%;min-height:280px;overflow:hidden;isolation:isolate;background:#f6f6f4;color:#162634;font-family:Arial,sans-serif;box-sizing:border-box}
.sf-frame-crop *{box-sizing:border-box}
.sf-frame-crop .fc-aperture-stage{position:absolute;left:50%;top:23px;width:420px;height:278px;transform:translateX(-50%)}
.sf-frame-crop .fc-aperture{position:absolute;inset:0;transition:clip-path 620ms cubic-bezier(.22,.8,.18,1);background:#1c5a68}
.sf-frame-crop .fc-aperture img,.sf-frame-crop .fc-image-context{position:absolute;width:420px;height:290.4626px;max-width:none;left:0;top:-6.2313px;object-fit:cover;user-select:none}
.sf-frame-crop .fc-image-context{opacity:.19}
.sf-frame-crop .fc-aperture-stage{overflow:visible}.sf-frame-crop .fc-image-context{clip-path:inset(6.2313px 0)}
.sf-frame-crop .fc-crop-edge{position:absolute;left:0;top:0;transition:transform 620ms cubic-bezier(.22,.8,.18,1),width 620ms cubic-bezier(.22,.8,.18,1),height 620ms cubic-bezier(.22,.8,.18,1);pointer-events:none}
.sf-frame-crop .fc-crop-edge i{position:absolute;width:12px;height:12px;border-color:#132c3d;border-style:solid;border-width:0}
.sf-frame-crop .fc-crop-edge i:nth-child(1){left:-5px;top:-5px;border-left-width:1px;border-top-width:1px}.sf-frame-crop .fc-crop-edge i:nth-child(2){right:-5px;top:-5px;border-right-width:1px;border-top-width:1px}.sf-frame-crop .fc-crop-edge i:nth-child(3){left:-5px;bottom:-5px;border-left-width:1px;border-bottom-width:1px}.sf-frame-crop .fc-crop-edge i:nth-child(4){right:-5px;bottom:-5px;border-right-width:1px;border-bottom-width:1px}
.sf-frame-crop .fc-formats{position:absolute;left:50%;bottom:20px;transform:translateX(-50%);margin:0;border:1px solid #e0e5e9;padding:3px;display:flex;gap:2px;border-radius:12px;background:#fff;box-shadow:0 1px 2px #12263a06}
.sf-frame-crop .fc-formats legend{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
.sf-frame-crop .fc-option{position:relative;display:block;cursor:pointer;touch-action:manipulation}
.sf-frame-crop .fc-option input{position:absolute;inset:0;opacity:0;width:100%;height:100%;margin:0;cursor:pointer}
.sf-frame-crop .fc-format-content{height:44px;min-width:78px;padding:0 12px;border-radius:8px;display:flex;align-items:center;justify-content:center;gap:9px;color:#74818a;font:11px Arial,sans-serif;transition:background-color 160ms ease,color 160ms ease}
.sf-frame-crop .fc-format-icon{display:block;border:1px solid currentColor;border-radius:2px}
.sf-frame-crop .fc-option input:checked+.fc-format-content{background:#eaf0f7;color:#143557}
.sf-frame-crop .fc-option input:focus-visible+.fc-format-content{outline:2px solid #2458a5;outline-offset:3px}
@media(hover:hover){.sf-frame-crop .fc-option:hover .fc-format-content{color:#143557;background:#f1f4f7}}
.sf-frame-crop .fc-option:active .fc-format-content{background:#e1e9f2}
.sf-frame-crop[data-active=false] *{transition:none!important}
@media(prefers-reduced-motion:reduce){.sf-frame-crop *{transition:none!important}}
`}</style>
</section>
)
}
Where it works well
Compare publishing formats while preserving subject scale and showing which parts of an artwork will be cropped.
Reduced motion
Format and crop position change immediately; the complete artwork remains visible as a faint context layer.
Things to know
Three authored crop positions, not automatic focal-point detection or a draggable/exporting image editor. Clip-path and bounded frame geometry animate; this is not a compositor-only effect. Sample artwork is a separately declared CC0 asset.