Subframe
Components & interactionsDraggable collage board

Collage board

Three original graphic studies can be moved into a persistent, overlapping composition.

Drag a study, or focus it and use arrow keys.

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

type Piece = { x: number; y: number; angle: number }
const initial: Piece[] = [
  { x: 0.3, y: 0.44, angle: -9 },
  { x: 0.59, y: 0.43, angle: 11 },
  { x: 0.51, y: 0.64, angle: -3 },
]
const names = ["Cut form", "Yellow interference", "Type study"]
export default function CollageBoard({ active = true }: { active?: boolean }) {
  const board = useRef<HTMLDivElement>(null)
  const [pieces, setPieces] = useState<Piece[]>(initial)
  const [selected, setSelected] = useState(2)
  const [dragging, setDragging] = useState<number | null>(null)
  const drag = useRef<{
    i: number
    x: number
    y: number
    start: Piece
    w: number
    h: number
    halfX: number
    halfY: number
  } | null>(null)
  useEffect(() => {
    const el = board.current
    if (!el) return
    const observer = new ResizeObserver(() => {
      const b = el.getBoundingClientRect()
      const sizes = Array.from(el.querySelectorAll("button")).map((button) => button.getBoundingClientRect())
      setPieces((p) =>
        p.map((v, i) => {
          const r = sizes[i]
          if (!r || !b.width || !b.height) return v
          const hx = r.width / b.width / 2,
            hy = r.height / b.height / 2
          return { ...v, x: Math.max(hx, Math.min(1 - hx, v.x)), y: Math.max(hy, Math.min(0.88 - hy, v.y)) }
        }),
      )
    })
    observer.observe(el)
    return () => observer.disconnect()
  }, [])
  function end() {
    drag.current = null
    setDragging(null)
  }
  function move(i: number, x: number, y: number, hx?: number, hy?: number) {
    const b = board.current?.getBoundingClientRect()
    const r = board.current?.querySelectorAll("button")[i]?.getBoundingClientRect()
    const halfX = hx ?? (b && r ? r.width / b.width / 2 : 0.16)
    const halfY = hy ?? (b && r ? r.height / b.height / 2 : 0.21)
    setPieces((p) =>
      p.map((v, n) =>
        n === i
          ? { ...v, x: Math.max(halfX, Math.min(1 - halfX, x)), y: Math.max(halfY, Math.min(0.88 - halfY, y)) }
          : v,
      ),
    )
  }
  return (
    <section className="sf-collage-board" data-active={active}>
      <div className="sf-cb-top">
        <button
          type="button"
          onClick={() => {
            end()
            setPieces(initial)
          }}
        >
          Reset ↺
        </button>
      </div>
      <div className="sf-cb-workspace" ref={board}>
        {pieces.map((p, i) => (
          <button
            type="button"
            key={i}
            className="sf-cb-piece"
            aria-label={`${names[i]}. Drag to arrange, or use arrow keys. Home resets.`}
            data-selected={selected === i}
            data-dragging={dragging === i}
            style={{
              left: `${p.x * 100}%`,
              top: `${p.y * 100}%`,
              transform: `translate(-50%,-50%) rotate(${p.angle}deg)`,
              zIndex: selected === i ? 5 : i + 1,
            }}
            onPointerDown={(e) => {
              const b = board.current?.getBoundingClientRect()
              if (!b) return
              const size = e.currentTarget.getBoundingClientRect()
              drag.current = {
                i,
                x: e.clientX,
                y: e.clientY,
                start: p,
                w: b.width,
                h: b.height,
                halfX: size.width / b.width / 2,
                halfY: size.height / b.height / 2,
              }
              setSelected(i)
              setDragging(i)
              e.currentTarget.setPointerCapture(e.pointerId)
              e.preventDefault()
              e.currentTarget.focus()
            }}
            onPointerMove={(e) => {
              const d = drag.current
              if (d && d.i === i)
                move(i, d.start.x + (e.clientX - d.x) / d.w, d.start.y + (e.clientY - d.y) / d.h, d.halfX, d.halfY)
            }}
            onPointerUp={end}
            onPointerCancel={end}
            onLostPointerCapture={end}
            onFocus={() => setSelected(i)}
            onKeyDown={(e) => {
              const x = e.key === "ArrowLeft" ? -0.025 : e.key === "ArrowRight" ? 0.025 : 0,
                y = e.key === "ArrowUp" ? -0.025 : e.key === "ArrowDown" ? 0.025 : 0
              if (x || y) {
                e.preventDefault()
                move(i, p.x + x, p.y + y)
              }
              if (e.key === "Home") {
                e.preventDefault()
                setPieces((v) => v.map((item, n) => (n === i ? initial[i] : item)))
              }
            }}
          >
            {i === 0 ? (
              <svg viewBox="0 0 150 185" aria-hidden="true">
                <rect width="150" height="185" fill="#f6f6f1" />
                <path
                  d="M29 21C58 1 126 24 120 53C118 67 88 62 81 72C75 80 121 101 117 128C112 163 41 174 29 147C18 122 64 115 66 96C69 73 9 54 29 21Z"
                  fill="#151716"
                />
                <path
                  d="M29 43C59 63 120 86 107 117M39 29C69 53 82 119 44 149"
                  fill="none"
                  stroke="#f6f6f1"
                  strokeWidth="2"
                />
                <text x="10" y="175" fill="#151716" fontSize="7" fontFamily="monospace">
                  CUT / FORM
                </text>
              </svg>
            ) : i === 1 ? (
              <svg viewBox="0 0 150 185" aria-hidden="true">
                <rect width="150" height="185" fill="#ffe414" />
                {Array.from({ length: 17 }, (_, j) => (
                  <ellipse
                    key={j}
                    cx="75"
                    cy="87"
                    rx={8 + j * 4.8}
                    ry={9 + j * 4.6}
                    fill="none"
                    stroke="#1a1b17"
                    strokeWidth="2.5"
                    transform={`rotate(${j * 4} 75 87)`}
                  />
                ))}
                <rect x="62" y="0" width="26" height="185" fill="#ffe414" />
                <path d="M64 0V185M87 0V185" stroke="#1a1b17" />
                <text x="9" y="174" fill="#151716" fontSize="7" fontFamily="monospace">
                  INTERFERENCE
                </text>
              </svg>
            ) : (
              <svg viewBox="0 0 150 185" aria-hidden="true">
                <rect width="150" height="185" fill="#141615" />
                <text x="-5" y="84" fill="#f5f5f0" fontFamily="Georgia,serif" fontSize="105" fontStyle="italic">
                  Aa
                </text>
                <text x="-9" y="171" fill="#f5f5f0" fontFamily="Georgia,serif" fontSize="109" fontStyle="italic">
                  Bb
                </text>
                <path d="M0 93H150" stroke="#8c8e88" strokeWidth=".5" />
              </svg>
            )}
          </button>
        ))}
      </div>
      <style>{`.sf-collage-board{width:100%;height:100%;min-height:280px;background:#e1e2df;color:#202220;position:relative;overflow:hidden;font-family:Arial,sans-serif;container-type:inline-size;box-sizing:border-box}.sf-collage-board *{box-sizing:border-box}.sf-cb-top{position:absolute;top:6px;left:22px;right:18px;height:44px;display:flex;justify-content:flex-end;align-items:center;z-index:7}.sf-cb-top strong{font:9px monospace;letter-spacing:.09em}.sf-cb-top button{min-height:44px;background:none;border:0;font:10px monospace;cursor:pointer;color:inherit}.sf-cb-workspace{position:absolute;inset:51px 0 0}.sf-cb-piece{width:clamp(100px,27cqw,151px);aspect-ratio:150/185;border:0;padding:0;position:absolute;cursor:grab;touch-action:none;background:none;box-shadow:0 2px 3px #1d201927,0 10px 12px #1d20191b;transition:box-shadow 140ms ease}.sf-cb-piece svg{width:100%;height:100%;display:block;pointer-events:none}.sf-cb-piece[data-dragging=true]{cursor:grabbing;box-shadow:0 3px 6px #1d201934,0 16px 22px #1d20192b}.sf-cb-piece:focus-visible{outline:2px solid #252825;outline-offset:5px}.sf-cb-top button:focus-visible{outline:2px solid #252825;outline-offset:2px}.sf-cb-caption{position:absolute;left:22px;bottom:13px;font:9px monospace;pointer-events:none}.sf-collage-board[data-active=false] .sf-cb-piece{transition:none}@media(prefers-reduced-motion:reduce){.sf-cb-piece{transition:none}}`}</style>
    </section>
  )
}

Where it works well

Freeform visual arrangement in a compact board.

Reduced motion

Positions change directly; raised shadow feedback is removed.

Things to know

Three local SVG studies; no image import or saved composition.

More examples