Subframe
Components & interactionsEmbossed paper lighting

Embossed paper proof

A movable studio light reveals the shallow relief of a blind-embossed mark on blue paper.

Drag the light or move it with 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, useId, useRef, useState } from "react"

type Point = { x: number; y: number }
const INITIAL: Point = { x: 148, y: 69 }
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))

export default function LightPosition({ active = true }: { active?: boolean }) {
  const [light, setLight] = useState<Point>(INITIAL)
  const [dragging, setDragging] = useState(false)
  const stage = useRef<HTMLDivElement>(null)
  const handle = useRef<HTMLButtonElement>(null)
  const drag = useRef<{ id: number; start: Point; offset: Point } | null>(null)
  const id = useId().replace(/:/g, "")
  const finish = (cancel = false) => {
    const current = drag.current
    drag.current = null
    setDragging(false)
    if (!current) return
    if (cancel) setLight(current.start)
    if (handle.current?.hasPointerCapture(current.id)) handle.current.releasePointerCapture(current.id)
  }
  useEffect(() => {
    if (!active) finish(true)
  }, [active])
  const update = (next: Point) => setLight({ x: clamp(next.x, 44, 556), y: clamp(next.y, 44, 331) })
  const point = (clientX: number, clientY: number) => {
    const bounds = stage.current!.getBoundingClientRect()
    return { x: ((clientX - bounds.left) / bounds.width) * 600, y: ((clientY - bounds.top) / bounds.height) * 375 }
  }
  const shadow = { x: (300 - light.x) * 0.047, y: (188 - light.y) * 0.047 }
  return (
    <div className="sf-raking" data-active={active} data-dragging={dragging} ref={stage}>
      <style>{`
.sf-raking{width:100%;height:100%;min-height:300px;position:relative;overflow:hidden;background:#10272e;isolation:isolate;color:#f1f0e6;font-family:Arial,sans-serif}.sf-raking *{box-sizing:border-box}.sf-raking-scene{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}.sf-raking-handle{position:absolute;left:0;top:0;width:74px;height:74px;margin:-37px 0 0 -37px;padding:0;border:0;border-radius:50%;background:transparent;cursor:grab;touch-action:none;display:grid;place-items:center;outline:none;-webkit-tap-highlight-color:transparent}.sf-raking-handle:focus-visible{outline:2px solid #e0ecbb;outline-offset:3px}.sf-raking-handle:disabled{cursor:default}.sf-raking[data-dragging=true] .sf-raking-handle{cursor:grabbing}.sf-raking-handle svg{width:38px;height:38px;overflow:visible;filter:drop-shadow(0 1px 2px #0d171955)}.sf-raking-label{position:absolute;top:50%;left:62px;transform:translateY(-50%);font-size:10px;font-weight:500;letter-spacing:.02em;line-height:16px;color:#d4dfd6;pointer-events:none}.sf-raking-help{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
      `}</style>
      <svg className="sf-raking-scene" viewBox="0 0 600 375" aria-hidden="true">
        <defs>
          <radialGradient id={`${id}-pool`} gradientUnits="userSpaceOnUse" cx={light.x} cy={light.y} r="410">
            <stop stopColor="#a8bbad" stopOpacity=".15" />
            <stop offset="1" stopColor="#111b22" stopOpacity="0" />
          </radialGradient>
          <filter
            id={`${id}-relief`}
            x="124"
            y="82"
            width="352"
            height="226"
            filterUnits="userSpaceOnUse"
            colorInterpolationFilters="sRGB"
          >
            <feGaussianBlur in="SourceAlpha" stdDeviation=".5" result="height" />
            <feDiffuseLighting
              in="height"
              surfaceScale=".4"
              diffuseConstant="1.35"
              lightingColor="#397bb8"
              result="lit"
            >
              <fePointLight x={light.x} y={light.y} z="150" />
            </feDiffuseLighting>
            <feComponentTransfer>
              <feFuncR type="linear" slope=".66" intercept=".12" />
              <feFuncG type="linear" slope=".66" intercept=".21" />
              <feFuncB type="linear" slope=".66" intercept=".3" />
            </feComponentTransfer>
          </filter>
          <filter id={`${id}-grain`} x="0" y="0" width="100%" height="100%">
            <feTurbulence type="fractalNoise" baseFrequency=".95" numOctaves="3" seed="17" stitchTiles="stitch" />
            <feColorMatrix type="saturate" values="0" />
            <feComponentTransfer>
              <feFuncA type="linear" slope=".24" />
            </feComponentTransfer>
          </filter>
          <filter id={`${id}-shadow`} x="-30%" y="-30%" width="160%" height="160%">
            <feGaussianBlur stdDeviation="8" />
          </filter>
          <clipPath id={`${id}-paper`}>
            <rect x="124" y="82" width="352" height="226" rx="1" />
          </clipPath>
        </defs>
        <rect width="600" height="375" fill={`url(#${id}-pool)`} />
        <rect
          x={124 + shadow.x}
          y={84 + shadow.y}
          width="352"
          height="226"
          rx="1"
          fill="#061a17"
          opacity=".58"
          filter={`url(#${id}-shadow)`}
        />
        <rect x="124" y="83.5" width="352" height="226" rx="1" fill="#17334f" />
        <g clipPath={`url(#${id}-paper)`}>
          <rect x="124" y="82" width="352" height="226" fill="#275381" />
          <g filter={`url(#${id}-relief)`} fill="white">
            <path transform="translate(0 28)" d="M221 227v-47a79 79 0 0 1 158 0v47h-15v-47a64 64 0 0 0-128 0v47z" />
            <path transform="translate(0 28)" d="M247 227v-47a53 53 0 0 1 106 0v47h-15v-47a38 38 0 0 0-76 0v47z" />
            <path transform="translate(0 28)" d="M273 227v-47a27 27 0 0 1 54 0v47h-15v-47a12 12 0 0 0-24 0v47z" />
          </g>
          <rect
            x="124"
            y="82"
            width="352"
            height="226"
            filter={`url(#${id}-grain)`}
            opacity=".8"
            style={{ mixBlendMode: "soft-light" }}
          />
          <rect x="124.5" y="82.5" width="351" height="225" fill="none" stroke="#acc1aa" strokeOpacity=".11" />
        </g>
      </svg>
      <button
        ref={handle}
        type="button"
        className="sf-raking-handle"
        disabled={!active}
        style={{ left: `${light.x / 6}%`, top: `${light.y / 3.75}%` }}
        aria-label={`Light position, horizontal ${Math.round(light.x / 6)} percent, vertical ${Math.round(
          light.y / 3.75,
        )} percent`}
        aria-describedby={`${id}-help`}
        aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight Home Escape"
        onBlur={() => {
          if (drag.current) finish(true)
        }}
        onPointerDown={(event) => {
          if (event.button !== 0 || drag.current) return
          const at = point(event.clientX, event.clientY)
          drag.current = { id: event.pointerId, start: light, offset: { x: at.x - light.x, y: at.y - light.y } }
          event.currentTarget.setPointerCapture(event.pointerId)
          setDragging(true)
        }}
        onPointerMove={(event) => {
          if (drag.current?.id !== event.pointerId) return
          const at = point(event.clientX, event.clientY)
          update({ x: at.x - drag.current.offset.x, y: at.y - drag.current.offset.y })
        }}
        onPointerUp={(event) => {
          if (drag.current?.id === event.pointerId) finish()
        }}
        onPointerCancel={(event) => {
          if (drag.current?.id === event.pointerId) finish(true)
        }}
        onLostPointerCapture={() => {
          if (drag.current) finish(true)
        }}
        onKeyDown={(event) => {
          if (event.key === "Escape") {
            if (drag.current) {
              event.preventDefault()
              finish(true)
            }
            return
          }
          if (event.key === "Home") {
            event.preventDefault()
            finish()
            setLight(INITIAL)
            return
          }
          if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return
          event.preventDefault()
          const step = event.shiftKey ? 24 : 8
          update({
            x: light.x + (event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0),
            y: light.y + (event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0),
          })
        }}
      >
        <svg viewBox="0 0 38 38" aria-hidden="true">
          <circle cx="19" cy="19" r="15" fill="#f3f5e8" fillOpacity=".025" stroke="#e1e9d2" strokeOpacity=".23" />
          <circle cx="19" cy="19" r="5" fill="#f7f8e9" />
          <path
            d="M19 8v3m0 16v3M8 19h3m16 0h3M11.2 11.2l2.1 2.1m11.4 11.4 2.1 2.1m0-15.6-2.1 2.1M13.3 24.7l-2.1 2.1"
            stroke="#e8edda"
            strokeWidth="1"
            strokeLinecap="round"
          />
        </svg>
        <span
          className="sf-raking-label"
          style={light.x > 500 ? { left: -10, transform: "translate(-100%, -50%)" } : undefined}
        >
          Light
        </span>
      </button>
      <span id={`${id}-help`} className="sf-raking-help">
        Drag the light to inspect the embossed paper. Arrow keys move the light; Shift moves farther. Home resets.
        Escape cancels a drag.
      </span>
    </div>
  )
}

Where it works well

Interactive material proofs, print finishing previews, and direct two-dimensional light controls.

Reduced motion

Light, relief shading, and cast shadow follow input immediately; no idle animation or transition.

Things to know

SVG point-light diffuse shading over an authored height mask with an approximate projected card shadow. No arbitrary 3D scene, uploaded material, or physically based renderer.

More examples