npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

headless-canvas

v0.5.0

Published

Headless, declarative React canvas for placing, moving, resizing, scaling and rotating positioned elements — zero visual styles, one pointer listener, geometric hit-testing. Your DOM stays yours.

Readme

headless-canvas

A headless, declarative React canvas for placing, moving, resizing, scaling and rotating positioned elements.

npm version License: MIT Peer: React >= 18 Zero runtime dependencies Live demo

Interactive demo (Storybook): https://antonlapshin.github.io/headless-canvas/ — drag, resize, scale and rotate the items to feel the interaction model.

headless-canvas gives you a complete drag-and-drop interaction engine with zero visual styles. It renders no handles, no outlines, no cursors, no colors — it renders structure and data attributes, and your CSS does the rest. Your DOM stays yours.

  • Headless — one pointer listener on the canvas root; hit-testing is pure coordinate math against a feature/item registry, never DOM events. Wrapping, portals or fragments around handles can never break interaction.
  • Declarative — compose what an item can do with JSX:
    <Canvas width={794} height={1123}>
      <Item x={80} y={120} features={<MoveHandle />}><h1>Title</h1></Item>
      <Item x={80} y={400} width={300} height={200}
            features={<><MoveHandle /><ResizeHandle direction="se" /></>} />
      <Item x={500} y={400} width={200} height={200} features={<ScaleHandle />} />
    </Canvas>
  • Fast — geometry lives in an external store (useSyncExternalStore); a drag re-renders only the dragged item. Zoom is a single CSS transform.
  • Universal — no <canvas> element involved: the library renders plain DOM, so text is text, images are images, and the same components render identically in your editor and your export pipeline.

The wrapping insight

const MoveHandleStyled = () => (
  <>
    <div className={styles.handle}><MoveIcon /></div>
    <MoveHandle />
  </>
);

<Item id="a" x={80} y={120} features={<MoveHandleStyled />}>…</Item>

Because hit-testing is geometric (coordinate math against a registry), the DOM tree under the canvas is presentation only. You can restyle any handle freely — the interaction can't break. One rule: the visual must be a sibling of the anchor, not a wrapper. The anchor div is positioned in item-local coordinates (left/top set by the library), so wrapping it in a positioned div shifts it off its hit region — the pixels and the interaction would diverge. Render your visual as a sibling and pin it to the corner with CSS (left/right/top/bottom), and they stay in lockstep with zero JS.

The default styled kit in stories/styled.tsx is a complete reference implementation of this pattern (heroicons glyphs, reserved corner positions, hover/selection visibility) — copy it into your app and restyle from there.

Install

npm install headless-canvas
# peer: react >= 18

The package ships ESM + CJS with bundled TypeScript declarations, and has zero runtime dependencies. The one structural stylesheet (positioning, touch-action, pointer-events) is auto-injected by the built JS — no CSS import needed. Vite/Webpack/Rollup/Next all work.

Quickstart

import { Canvas, Item, MoveHandle, ResizeHandle, RotateHandle } from 'headless-canvas';

// consumer stylesheet (CSS modules, Tailwind, plain CSS — your choice)
import styles from './editor.module.css';

// The visual is a SIBLING of the anchor — see "The wrapping insight" below.
const MoveHandleStyled = () => (
  <>
    <div className={styles.handle}><MoveIcon /></div>
    <MoveHandle />
  </>
);

export function Editor() {
  return (
    <Canvas width={794} height={1123} snapToGrid={10} aria-label="Page editor">
      <Item id="title" x={80} y={120} features={<MoveHandleStyled />}>
        <h1>Slow Living Autumn</h1>
      </Item>
      <Item
        id="photo"
        x={80} y={400} width={300} height={200}
        features={<><MoveHandleStyled /><ResizeHandle direction="se" /><RotateHandle /></>}
      >
        <img src="..." alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
      </Item>
    </Canvas>
  );
}

Selected / hovered / locked states are driven by data attributes, so they're pure CSS:

.item[data-selected="true"]  { outline: 2px dashed #3b82f6; }
.item[data-hovered="true"]   { box-shadow: 0 0 0 2px #3b82f6; }
.item[data-locked="true"]    { opacity: .7; }
.handle { position: absolute; width: 12px; height: 12px; cursor: move; ... }

API reference

<Canvas>

| Prop | Type | Default | Description | |---|---|---|---| | width / height | number | — | Required. Logical size of the design space (e.g. 794×1123 = A4 @96dpi). | | scale | number | 1 | Display zoom. Children are authored in logical units; scale maps to CSS px. A single CSS transform — no per-item transforms. | | snapToGrid | number | off | Snap dragged geometry to this grid (logical units). | | constraints | Constraints | canvas edges | { minX, minY, maxX, maxY, minWidth, minHeight } — bounds enforced on dragged geometry. Defaults to the canvas edges: items can never leave the canvas. Override individual edges to tighten ({ minX: 20 }) or extend ({ maxX: 700 }) them; the rest stay pinned to the canvas. | | disabled | boolean | false | Disable all interaction (still renders + context available). | | selectedId / onSelect | string \| null / fn | uncontrolled | Controlled selection. Omit for uncontrolled. | | items / onItemsChange | ItemGeometry[] / fn | uncontrolled | Controlled geometry: pass the full array; drags are reported (rAF-throttled during drag, exact on end) and the parent round-trips. | | onDragStart / onDragEnd | (id, kind) => void | — | Drag lifecycle, with the drag kind ('move' \| 'resize' \| 'rotate' or a custom kind). | | onItemDoubleClick | (id) => void | — | Double-click on an item body/feature. | | aria-label | string | — | Accessible name for the region (rendered as role="group"). | | className / style | — | — | Pass-through hooks for consumer CSS. | | ref | CanvasHandle | — | Imperative API (below). |

CanvasHandle (via ref)

| Method | Description | |---|---| | getItems() | All geometries, ordered by effective z (render order). | | setItems(items) | Replace all geometries. | | select(id \| null) | Select or deselect. | | bringToFront(id) / sendToBack(id) | Reorder via zIndex = max+1 / min−1. |

ItemGeometry

{ id: string; x: number; y: number;
  width?: number; height?: number;   // undefined = natural content size (measured)
  rotation?: number;                 // degrees around center, default 0
  zIndex?: number;                   // default: render order
  locked?: boolean }                 // blocks transforms; selection + focus stay

<Item>

| Prop | Description | |---|---| | id | Unique id — the item's identity in the store, hit-testing and selection. | | x, y, width, height, rotation, zIndex, locked | Initial geometry. In uncontrolled mode, props are initial values — the store owns geometry afterwards (so re-rendering with different props never loses drag state). In controlled mode, the items prop wins. | | features | Declarative affordances: <MoveHandle />, <ResizeHandle />, <RotateHandle />, plus the readouts <EdgeLines />, <RotateValue />, <ResizeValue /> — or your own feature components. | | className / style | Pass-through hooks for consumer CSS. | | children | Item content — any React node. Auto-sized items are measured with a ResizeObserver. |

Features (handles)

Each handle renders one invisible anchor <div> (data-feature, pointer-events: none, no classes, no visuals) and registers its geometric hit region + drag behavior. No-op on locked items and disabled canvases.

| Handle | Props | Behavior | |---|---|---| | MoveHandle | cursor? (default 'move') | Hit region = a small square at the item's top-left corner (where the styled kit draws the move glyph). The body is deliberately not a move region: body clicks pass through to the item's content, so buttons/selects/links inside items stay interactive. | | ResizeHandle | direction?n/s/e/w/ne/nw/se/sw (default 'se'), lockRatio? (default false), cursor? | Resizes that edge/corner; n/w handles keep the opposite edge fixed. Min size 8 (configurable via constraints). With lockRatio the aspect ratio is preserved — corner handles scale proportionally from the opposite corner (the former ScaleHandle behavior), edge handles scale the perpendicular axis around the item center. No-op on auto-sized items. | | RotateHandle | offset? (default 24), cursor? | Sits above the item top-center; drag in a circle to rotate (normalized to [0, 360)). |

Readouts — passive features that render only while the relevant drag is active (no hit region, pointer-events: none). They carry zero visual styles: line color/width and number appearance are consumer CSS driven by the data attributes below.

| Readout | Shows | While | |---|---|---| | EdgeLines | One line from each item edge straight to the corresponding canvas edge (lines never stop at other items), with the pixel distance in the middle of each line — Figma-style measurement. Lines stay vertical/horizontal even when the item is rotated (distances come from the item's unrotated box) | the item is moved | | RotateValue | The current angle (e.g. 45°) | the item is rotated | | ResizeValue | Live width × height in px | the item is resized |

Custom features — build your own affordances (a "crop" handle, a link handle…) on the same canvas-level pointer pipeline:

function CropHandle() {
  const { id } = useItem();
  const canvas = useCanvas();
  useFeatureRegistration(id, 'crop', () => ({           // item-local hit region
    x: canvas.getItem(id)!.width! - 12, y: canvas.getItem(id)!.height! - 12,
    width: 24, height: 24,
  }), {
    cursor: 'crosshair',
    onDrag: ({ dx, dy, start }) => ({                   // return a geometry patch
      width: (start.width ?? 0) + dx,
      height: (start.height ?? 0) + dy,
    }),
  });
  return <div className={styles.crop} />;                // your visual, your styles
}

Hooks

| Hook | Returns | |---|---| | useCanvas() | { width, height, scale, selectedId, hoveredId, activeDrag, select, getItem, updateItem, snap, toLogical } — throws outside <Canvas>. getItem/updateItem read/write the store; snap snaps to the configured grid; toLogical maps client → logical coordinates; activeDrag is { itemId, kind, direction? } while a drag is in progress (drives the readouts). | | useItem() | { id, geometry } for the nearest enclosing <Item> — re-renders when that item's geometry changes. | | useFeatureRegistration(itemId, kind, getHitRect, options?) | Register a custom hit region + drag behavior (see above). |

Styling guide

The library ships one structural SCSS module (.canvas, .layer, .item, .feature — positioning, touch-action, pointer-events) and nothing else. No colors, no borders, no cursors, no shadows. No Tailwind, no CSS-in-JS, no inline decorative styles. In particular the library sets no user-select: text inside items is selectable and any content (buttons, selects, links, inputs…) works exactly as it would outside the canvas — the item is just a positioned box around your DOM.

Data-attribute contract (stable, documented)

| Element | Attributes | |---|---| | Canvas root | data-canvas | | Zoom layer | data-canvas-layer | | Item wrapper | data-item-id, data-selected, data-hovered, data-locked, data-disabled (booleans) | | Feature anchor | data-feature="move\|resize\|rotate", data-direction="se" etc. | | Edge line | data-edge-line="top\|bottom\|left\|right", label data-edge-value (px) | | Value readout | data-feature="rotate-value" (with data-value), data-feature="resize-value" (with data-width / data-height) |

Style everything from these:

[data-canvas] { border: 1px solid #d4d4d8; background: #fff; }
[data-item-id]:focus-visible { outline: 2px solid #3b82f6; }
[data-selected="true"] { box-shadow: 0 0 0 2px #3b82f6; }
[data-selected="true"] .handle, [data-hovered="true"] .handle { opacity: 1; }
[data-locked="true"] .handle { opacity: .4; }

Convention for handle visibility — the styled kit hides handles by default and fades them in with [data-hovered="true"] / [data-selected="true"] (plus :focus-within for keyboard users). The kit's reserved corner positions are pure CSS pins: move at left/top, resize at right/bottom, rotate above top-center.

Styling the readouts — EdgeLines lines are <div data-edge-line> children with --hc-edge-thickness (default 1px) controlling line width; labels are data-edge-value pills. The RotateValue / ResizeValue spans carry data-edge-value too, so the same pill rule styles them. The styled kit's measurement CSS (red lines, dark number pills — Figma-style) is a copy-ready reference.

Selected items render on top — while an item is selected the canvas raises its z-index above every other item (transient, in the DOM only — the store's zIndex is never mutated) and hit-testing agrees, so the selected item always wins the pointer.

Cursors for the built-in handles are applied by the canvas root while hovering (the anchors are pointer-events: none), so the cursor prop on each handle works out of the box — override by wrapping.

Interaction & accessibility

  • Pointer model — one onPointerDown/Move/Up set on the canvas root with pointer capture; hit-testing is geometric (features → item bodies → empty, topmost first; the selected item is always topmost). Item body clicks are ignored — selection happens via handles, keyboard focus or the select API — so the DOM inside items (buttons, selects, links…) receives clicks untouched and its text is selectable like any other DOM. Mouse and touch are unified via pointer events; multi-touch (pinch) is not v1. (On touch devices the canvas needs touch-action: none for drags, which also disables the long-press selection handles — text selection there is desktop/mouse territory.)
  • Bounds — items can never leave the canvas: move, resize and keyboard moves clamp to the canvas edges (override individual edges with constraints). Programmatic writes (updateItem, controlled items) are not clamped — consumers own those.
  • Keyboard — items are focusable (tabIndex=0). When focused: arrows move 1px, Shift+arrows resize 1px from the top-left, r/R rotate ±15°, Esc deselects. Delete is deliberately not handled — consumers own deletion.
  • ARIA — canvas root is role="group" with your aria-label; items expose aria-selected; feature anchors carry aria-label ("Move item", …) and aria-disabled.
  • Locked items — selection and focus stay; all transforms are blocked.
  • Disabled canvas — everything off; still renders and provides context.

Performance

  • One pointer listener on the root (not N items).
  • Hit-testing: registry scan, topmost-first — O(features + items); a uniform-grid spatial index is the documented upgrade path beyond ~1–2k items.
  • Per-item store subscriptions: only the dragged item re-renders per frame; features re-render only when their item's geometry changes.
  • onItemsChange is rAF-throttled during drag; onDragEnd always fires an exact final payload.
  • React.memo on Item and features; stable callback identities.
  • Zoom is a single CSS transform on one layer — no per-item transforms.
  • See the Perf story: 500 draggable items with a live frame-time readout.

Controlled vs uncontrolled

| Mode | How | Reading geometry | |---|---|---| | Uncontrolled (default) | Canvas owns the store; Item props are initial values | onItemsChange (throttled during drag, exact on end), ref.getItems() | | Controlled | Pass items; the canvas mirrors the prop (prop changes win, skipped mid-drag) | Round-trip onItemsChange → state → items (standard controlled pattern) |

Selection has the same duality via selectedId / onSelect.

FAQ

Why no <canvas> element? Because the WYSIWYG bet matters: the same components must render in the editor and in export (print PDF, thumbnails). DOM text is selectable, searchable, and pixel-identical everywhere. The name refers to the design surface, not the rendering API.

Why exactly one SCSS file? Structure is the library's job; appearance is yours. A single 350-byte structural module keeps the package honest: zero visual opinions, zero styling-system lock-in.

How do I add a custom handle? useFeatureRegistration — see the custom-feature example above. It's the same pipeline the built-in handles use.

My item has no width/height — why won't it resize? Auto-sized items are measured (content box) and rendered at natural size, but resizing/scaling requires explicit dimensions by design. Pass width/height to enable them.

Why does the canvas show nothing? It renders only what you put in it — that's the point. Structure comes from the injected stylesheet; appearance comes from your CSS on the data attributes.

Does it work with touch? Yes — pointer events unify mouse and touch. Pinch-zoom is a v2 candidate.

What's the license? MIT.

Roadmap (v2 candidates)

Multi-select/grouping · snap-to-canvas-grid guides · pinch zoom · custom drag shapes (paths) · in-canvas text editing · a spatial index for very large canvases.

License

MIT © Anton Lapshin