react-page-decorator
v0.3.1
Published
Drag-and-drop visual placement of images/illustrations anywhere on a React page. Dev editor + ships-in-code renderer, responsive per-breakpoint placements, pluggable persistence (localStorage / REST file).
Maintainers
Readme
react-page-decorator
Drop images / illustrations / PNG icons anywhere on a React page by dragging them, then save. Ships two halves:
- Renderer — paints saved decorations over the page (runs in production).
- Dev editor — a drag/resize/rotate overlay; saving is delegated to a pluggable storage adapter.
Features: absolute placement as page-percentages (responsive), per-breakpoint
placements (a different size and area on mobile vs desktop), drag-to-move,
corner-resize, corner-rotate, anchor a decoration to any element (click-to-pick
selector), an image-path picker, optional next/image optimization, and
inline-styled UI (no Tailwind or CSS import required — works in any React project).
Install
pnpm add react-page-decoratorreact / react-dom are peer dependencies (>=18).
Zero-config (one line)
Render <DecorationAuto /> once — typically in your root layout. No CSS, no
adapter, no position: relative, no props. It portals a full-page click-through
layer into <body>, defaults to localStorage, and auto-opens the editor on
?edit=1 in development.
// app/layout.tsx (Next.js) — or your top-level App component
import { DecorationAuto } from "react-page-decorator";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<DecorationAuto />
</body>
</html>
);
}Run dev, open ?edit=1, place images, Save → they persist to localStorage.
An npm package can't render itself — bundlers only include code you import — so this one mount is the minimum. Everything else is automatic. For server-rendered decorations or file-backed (commit-to-code) storage, use
<DecorationCanvas>directly (below).
Concepts
A Decoration stores a Placement per breakpoint (desktop is required,
mobile optional). The renderer resolves the placement for the current window
width. You provide:
items— the saved decorations (e.g. imported from a JSON file you commit).adapter— how toload/save(see below).enabled— whether the editor is active (your call, e.g. dev +?edit=1).
Quick start (any React app, zero backend)
localStorageAdapter persists in the browser — nothing else to set up. Omit
enabled and the editor auto-opens on ?edit=1 in development:
"use client";
import { DecorationCanvas, localStorageAdapter } from "react-page-decorator";
const adapter = localStorageAdapter("my-site-decorations");
export function PageDecorations() {
// Mount inside a `position: relative` element that wraps the whole page.
return <DecorationCanvas adapter={adapter} />;
}Open ?edit=1, place images, Save. They reload from localStorage next visit.
Edit mode: with
enabledomitted, the editor turns on automatically when the URL has?edit=1and it's not a production build. Pass an explicitenabled={…}to control it yourself (e.g. behind your own flag).
Commit-to-code (Next.js App Router)
Persist to a JSON file so placements ship in your repo. Use restAdapter + a
tiny route handler, and feed the committed file as items for SSR/prod.
// components/PageDecorations.tsx
"use client";
import { useEffect, useState } from "react";
import { DecorationCanvas, normalizeMany, restAdapter } from "react-page-decorator";
import saved from "@/data/decorations.json";
const ITEMS = normalizeMany(saved);
const adapter = restAdapter({ saveUrl: "/api/dev/decorations" });
export default function PageDecorations() {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
if (process.env.NODE_ENV !== "production") {
setEnabled(new URLSearchParams(location.search).get("edit") === "1");
}
}, []);
return <DecorationCanvas items={ITEMS} adapter={adapter} enabled={enabled} />;
}// app/api/dev/decorations/route.ts
import { mkdir, writeFile } from "fs/promises";
import path from "path";
import { NextResponse } from "next/server";
import { normalizeMany } from "react-page-decorator/model"; // server-safe subpath
export async function POST(req: Request) {
if (process.env.NODE_ENV === "production") {
return NextResponse.json({ error: "disabled" }, { status: 403 });
}
const items = normalizeMany(await req.json().catch(() => null));
const file = path.join(process.cwd(), "data", "decorations.json");
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(items, null, 2)}\n`, "utf8");
return NextResponse.json({ ok: true, count: items.length });
}Import model helpers from
react-page-decorator/modelin server code — it has no client components, so it won't pull React UI into your server bundle.
Optional: image picker
Pass assets (a string[] of image URLs) or assetsLoader (async) to populate
the path field's autocomplete. In Next, point an assetsLoader at a dev route
that lists files under public/.
Optional: optimized images (next/image)
By default decorations render as a lazy-loaded <img>. Pass renderImage to
swap in an optimized component. The editor records each image's aspect ratio, so
height is supplied for exact sizing (no distortion / layout shift):
import Image from "next/image";
import type { RenderImage } from "react-page-decorator";
const renderImage: RenderImage = ({ src, width, height, style }) =>
height ? (
<Image src={src} alt="" width={width} height={height} style={style} />
) : (
<img src={src} alt="" loading="lazy" decoding="async" style={style} />
);
<DecorationCanvas items={ITEMS} adapter={adapter} renderImage={renderImage} />;
heightis only present once a decoration has been opened in the editor (which captures the aspect ratio) and saved. Until then the fallback<img>is used.
Using the editor
Open your page with ?edit=1 (dev). A panel appears bottom-right:
- Add — type or pick an image path, click Add. It drops at the center of your current view, already selected.
- Move — drag the image body.
- Resize — drag the square handle (bottom-right corner).
- Rotate — drag the round handle (top-right corner).
- Tune — the panel edits width, rotation, opacity and z-index of the selected decoration; Delete removes it.
- Responsive — the chip shows the active breakpoint (Desktop/Mobile). Resize your window narrow (<768px) to edit the mobile placement; mobile inherits desktop until first edited at mobile width.
- Save — persists the whole set via your adapter.
Anchoring to an element
By default a decoration is positioned relative to the whole page. To pin it to a specific element (so it stays put as content above it reflows), give it an anchor:
- Select the decoration, then click Pick in the panel.
- Hover the page — elements highlight; click the one you want. A CSS selector
is generated and stored as the decoration's
anchor. (Or type a selector directly; blank = whole page. Esc cancels picking.)
The image is then portaled into that element and positioned as a percentage of its box. Anchored decorations render client-side (portals need the live DOM), so they appear just after hydration — see Rendering below.
Rendering: instant vs. deferred
Whether a decoration is in the initial server HTML (instant) or appears just after hydration (a brief pop-in) depends on how it's set up:
| Setup | Instant (SSR)? |
| --- | --- |
| DecorationCanvas + committed items (a JSON file), page-relative | ✅ yes |
| localStorageAdapter | ❌ data lives in the browser, not the server |
| DecorationAuto | ❌ it self-mounts via a client-only portal |
| Anchored decoration | ❌ portals are client-only |
For fully instant images: use <DecorationCanvas> with committed items,
mounted inside a position: relative wrapper, and keep the decoration
page-relative (no anchor). That's the only combination the server can render up
front — localStorage and portals are inherently client-side.
API
| Export | What |
| --- | --- |
| DecorationAuto | Zero-config: self-mounts to <body>, localStorage by default, auto-opens on ?edit=1 (client-rendered). |
| DecorationCanvas | The main component: renderer + editor + adapter wiring. Props: items, adapter, enabled?, assets?/assetsLoader?, renderImage?. SSR-friendly. |
| Decorations | Renderer only (if you want to gate the editor yourself). |
| localStorageAdapter(key?) / restAdapter({ saveUrl, loadUrl?, init? }) | Storage adapters. |
| renderImage prop + RenderImage type | Swap the default <img> for an optimized component (e.g. next/image). |
| normalizeMany / normalizeDecoration | Coerce stored JSON into valid Decoration[]. |
| resolveDecoration, decorationStyle, breakpointForWidth, BREAKPOINTS | Lower-level helpers. |
| Types: Decoration, Placement, ResolvedDecoration, BreakpointKey, StorageAdapter | |
Notes
- Mount the canvas inside a
position: relativepage wrapper so the absolute layer spans the whole page. - Decorations are
pointer-events: nonein view mode — they never block clicks. - Edit the breakpoint you want by resizing the window; the panel shows which one is active. Mobile inherits desktop until first edited at mobile width.
