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

react-image-spots

v0.2.1

Published

Interactive image maps and multi-layer scene compositing for React, with a visual edit mode

Readme

react-image-spots

Interactive image hotspots for React. Define spots in code, render anything, navigate between scenes with animations.

npm tests license

preview

Live demo →


Install

npm install react-image-spots

Requires React 17+. No runtime dependencies.


Collecting Spot Positions

Switch to mode="edit" during development. Click anywhere on the image - the x/y position is instantly copied to your clipboard.

<ImageSpotMap
	mode='edit'
	src='/my-photo.jpg'
	onSpotPlace={(pos, index) => console.log(pos)}
	// pos = { x: 32.5, y: 41.2 }
/>

Image Spot Map

Positions are in % so they scale responsively. On hover, other spots fade out automatically.

import { ImageSpotMap } from 'react-image-spots'
import type { SpotDef } from 'react-image-spots'

const spots: SpotDef[] = [
  {
    id: 'kitchen',
    position: { x: 32.5, y: 41.2 },
    size: { w: 5, h: 5 },
    render: ({ isHovered, onMouseEnter, onMouseLeave, onClick }) => (
      <div
        onMouseEnter={onMouseEnter}
        onMouseLeave={onMouseLeave}
        onClick={onClick}
        style={{
          width: '100%',
          height: '100%',
          borderRadius: '50%',
          background: isHovered ? '#3b82f6' : 'white',
          border: '2px solid #3b82f6',
        }}
      />
    ),
  },
]

<ImageSpotMap src="/floor-plan.jpg" spots={spots} />

Image Swap

Add hoverSrc or activeSrc to swap the main image on hover or click.

{
  id: 'room',
  position: { x: 40, y: 50 },
  hoverSrc: '/room-hover.jpg',
  activeSrc: '/room-click.jpg',
  render: (props) => <MySpot {...props} />,
}

Swap Animations

10 built-in presets or custom CSS keyframes. Set globally or per-spot - per-spot overrides global.

// Global
<ImageSpotMap src="/map.jpg" spots={spots} swapAnimation="zoom" />

// Per-spot override
{
  id: 'bridge',
  hoverSrc: '/bridge.jpg',
  swapAnimation: 'glitch',
  render: (props) => <MySpot {...props} />,
}

// Custom keyframes
<ImageSpotMap
  src="/map.jpg"
  spots={spots}
  swapAnimation={{
    enter: "my-anim 400ms ease forwards",
    leave: "my-exit 400ms ease forwards",
    duration: 400,
  }}
/>

Available presets: fade blur zoom zoom-out slide-up slide-down slide-left slide-right flip glitch none


Scene Chain

Navigate between multiple images with smooth transitions.

import { SceneChain } from 'react-image-spots'
import type { SceneDef } from 'react-image-spots'

const scenes: Record<string, SceneDef> = {
  world: {
    src: '/world-map.jpg',
    spots: [
      {
        id: 'castle',
        position: { x: 45, y: 35 },
        size: { w: 6, h: 6 },
        render: ({ onMouseEnter, onMouseLeave, onClick, goTo }) => (
          <button
            onMouseEnter={onMouseEnter}
            onMouseLeave={onMouseLeave}
            onClick={(e) => { onClick(e); goTo('castle') }}
            style={{ width: '100%', height: '100%', borderRadius: '50%' }}
          />
        ),
      },
    ],
  },
  castle: { src: '/castle.jpg', spots: [] },
}

<SceneChain
  scenes={scenes}
  initialScene="world"
  transition="fade"
  showBreadcrumb={false}
  renderBackButton={({ goBack }) => (
    <button onClick={goBack} style={{ position: 'absolute', top: 16, left: 16 }}>
      Back
    </button>
  )}
/>

Layer Scene

LayerScene composites several independent image and/or icon layers - no single "background" required, unlike ImageSpotMap. Use it for arranging standalone assets (a mascot PNG, a phone-mockup PNG, a handful of decorative icons) rather than adding hotspots to one photo.

Copy-paste quickstart:

npm install react-image-spots lucide-react
import { LayerScene, SpotsEditProvider } from 'react-image-spots'
import type { LayerDef } from 'react-image-spots'

const layers: LayerDef[] = [
  {
    id: 'mascot',
    type: 'image',
    src: '/mascot.png',
    position: { x: 38, y: 52 }, // %
    width: 260,                  // px
    height: 150,                 // px
    rotate: -3,
    zIndex: 1,
  },
  {
    id: 'pin',
    type: 'icon',
    iconName: 'MapPin',          // any lucide-react export name
    position: { x: 68, y: 28 },
    size: 34,                    // px
    rotate: 0,
    color: '#3b82f6',
    zIndex: 2,
    animation: 'float',
  },
]

// Wrap your whole app (or a page) once. SpotsEditProvider renders its
// own floating "Edit this page" toggle - click it to flip edit mode
// for every LayerScene underneath it at the same time.
<SpotsEditProvider>
  <LayerScene layers={layers} onChange={console.log} />
</SpotsEditProvider>

That's the whole setup - no other configuration needed. LayerScene (and the hooks it uses internally) are already marked "use client" in the published bundle, so it works out of the box inside a Next.js App Router server-component tree.

Optional: icon layers (lucide-react)

lucide-react is an optional peer dependency - install it only if you use IconLayerDef layers. Image-only scenes work with zero lucide-react installed. If an icon layer is rendered without lucide-react present, it shows a dashed placeholder instead of crashing.

npm install lucide-react

Icons are looked up dynamically by name through resolveIcon(iconName) (src/iconRegistry.ts), so LayerDef stays plain, JSON-serializable data - icon layers store a string name ("Coffee"), never a component reference.

Edit mode

SpotsEditProvider's floating toggle (bottom-right by default) turns edit mode on for every LayerScene on the page at once - no manual switch, no threading a mode prop through each component. An explicit mode="edit" on an individual LayerScene still overrides it, if you want to control just one instance yourself:

<LayerScene
  layers={layers}
  mode="edit"                 // overrides SpotsEditProvider's global toggle
  onChange={(next) => setLayers(next)}
  onExport={(next) => saveDraft(next)}   // clipboard copy + this callback
  autoExportOnChange           // also fires onExport ~500ms after any change
/>

Once editing:

  • Toolbar - a genuine card above the scene box (never glued to its edge or overlaid on top of it): add-icon, bring-to-front, send-to-back, duplicate, delete, and export, as icon buttons (rendered with lucide-react if installed, plain-text fallback otherwise) with title tooltips. Reorder/duplicate/delete stay visible at all times, just disabled until a layer is selected.
  • Resize handle - visible square in the bottom-right corner of the selected layer.
  • Rotate handle - visible circular handle above the selected layer.
  • Layer panel - a separate card beside the scene box, listing every layer (thumbnail, type, z-index), so a layer hidden behind another is still one click away.
  • Live readout - below the scene box, the selected layer's x/y/width/height/rotate, read directly from its DOM getBoundingClientRect() as you drag.
  • Icon browser - the add-icon button opens a searchable icon grid (debounced ~150ms) with live icon previews. Search matches substrings and CamelCase-split words, so "shopping bag" finds ShoppingBag.

The scene box itself only ever renders the composed layers plus the selected layer's outline and resize/rotate handles - exactly what an end user would see in preview. Everything else lives outside it, so stacking multiple LayerScene instances (or one alongside SpotsEditProvider's backgroundLayers canvas) never causes their chrome to visually collide.

Layer animations

Same shape as ImageSpotMap's swapAnimation - a preset string, a configured preset with tunable duration/intensity, or fully custom keyframes:

| Preset | Effect | | ------- | -------------------------------------------------------------- | | none | No animation (default) | | float | Subtle, slow translateY drift - for decorative icons/mascots | | pulse | Slow opacity pulse | | spin | Continuous 360° rotation | | sway | Gentle rotation back and forth |

// Plain preset - same subtle look as always
{ id: 'pin', type: 'icon', iconName: 'MapPin', animation: 'float', /* ... */ }

// Configured preset - tune speed and amplitude per layer
{
  id: 'pin',
  type: 'icon',
  iconName: 'MapPin',
  animation: { type: 'float', duration: 2, intensity: 0.8 }, // faster, bigger drift
  /* ... */
}

// Custom keyframes - full control
{
  id: 'pin',
  type: 'icon',
  iconName: 'MapPin',
  animation: { keyframes: 'my-anim 3s ease-in-out infinite', duration: 3000 },
  /* ... */
}

LayerAnimationConfig fields (both optional - omit either and it falls back to the exact default the plain preset string uses, so configurability never changes the unconfigured look):

| Field | Type | Default (by type) | Controls | | ----------- | -------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | duration | number (seconds) | float 4s · pulse 2.2s · spin 6s · sway 3.2s | One full animation cycle's length | | intensity | number (0-1) | float 0.3 · pulse 0.5 · sway 0.3 · ignored for spin/none | Amplitude: float = px of translateY (0-1 maps to 0-20px) · pulse = how far opacity dips (0-1 maps to a 0-80% swing) · sway = rotation range in degrees (0-1 maps to 0-10°) |

In edit mode, selecting a layer with an animation other than none shows Speed and Intensity sliders next to the animation picker (a custom popover, not a native <select>) (Intensity is hidden for spin, since a full rotation doesn't have an amplitude) - both update the live preview immediately as you drag.

Responsive layers

Any layer can override position / width / height / size / rotate per breakpoint - missing fields fall back to the base values:

{
  id: 'mascot',
  type: 'image',
  src: '/mascot.png',
  position: { x: 50, y: 50 },
  width: 260,
  height: 150,
  responsive: {
    mobile: { position: { x: 50, y: 35 }, width: 160, height: 92 },
    tablet: { width: 210, height: 121 },
    // desktop: falls back to the base position/width/height above
  },
}

LayerScene resolves its own breakpoint from its actual rendered width - not the browser window's - so it responds correctly to anything that constrains its width, not just resizing the real window. Default thresholds are mobile: 640, tablet: 1024 (px) - override with the breakpoints prop:

<LayerScene layers={layers} breakpoints={{ mobile: 480, tablet: 900 }} />

While editing, SpotsEditProvider's floating Mobile/Tablet buttons simulate a device viewport by resizing the whole page - not just one component - to that width (like a device-preview frame), so every LayerScene on the page picks up the matching breakpoint automatically. Dragging a layer while "Mobile" is simulated writes to that layer's responsive.mobile override, not the base values.


API

ImageSpotMap

| Prop | Type | Default | Description | | ------------------- | -------------------------------------------- | ----------- | --------------------------------------- | | src | string | required | Image URL | | mode | 'preview' \| 'edit' | 'preview' | Interactive spots or position collector | | spots | SpotDef[] | [] | Spot definitions | | onSpotPlace | (pos, index) => void | - | edit mode: fired on click | | enableImageSwap | boolean | true | Enable image swap on hover/click | | swapDuration | number | 400 | Swap duration ms | | swapAnimation | SwapAnimationPreset \| SwapAnimationCustom | 'fade' | Global swap animation | | hideOthersOnHover | boolean | true | Fade out other spots on hover | | hideHoveredSpot | boolean | false | Also hide the hovered spot | | onSpotHover | (spot) => void | - | Fired on mouse enter | | onSpotClick | (spot) => void | - | Fired on click | | onSpotLeave | (spot) => void | - | Fired on mouse leave |

SceneChain

| Prop | Type | Default | Description | | -------------------- | ----------------------------------------------- | ---------- | ----------------------------- | | scenes | Record<string, SceneDef> | required | Map of sceneId → scene | | initialScene | string | required | Starting scene ID | | transition | 'fade' \| 'none' | 'fade' | Scene transition | | transitionDuration | number | 350 | Duration ms | | swapAnimation | SwapAnimationPreset \| SwapAnimationCustom | 'fade' | Spot swap animation | | hideOthersOnHover | boolean | false | Fade out other spots on hover | | renderBackButton | ({ goBack, canGoBack, history }) => ReactNode | - | Custom back button | | showBackButton | boolean | true | Show built-in back button | | backButtonLabel | string | '← Back' | Built-in back button label | | showBreadcrumb | boolean | true | Show scene breadcrumb | | onSceneChange | (id, scene) => void | - | Fired on scene change | | onSpotClick | (spot, sceneId) => void | - | Fired when spot is clicked |

SpotDef

| Prop | Type | Default | Description | | --------------- | -------------------------------------------- | ---------------- | --------------------------- | | id | string | required | Unique identifier | | position | { x: number, y: number } | required | Position in % (0–100) | | size | { w: number, h: number } | { w: 5, h: 5 } | Hitbox size in % | | render | (props: SpotRenderProps) => ReactNode | required | Your render function | | hoverSrc | string | - | Image URL to swap on hover | | activeSrc | string | - | Image URL to swap on click | | swapAnimation | SwapAnimationPreset \| SwapAnimationCustom | - | Per-spot animation override |

SpotRenderProps

interface SpotRenderProps {
	isHovered: boolean;
	isActive: boolean;
	onMouseEnter: (e: MouseEvent) => void;
	onMouseLeave: (e: MouseEvent) => void;
	onClick: (e: MouseEvent) => void;
	goTo: (sceneId: string) => void; // SceneChain only
	goBack: () => void; // SceneChain only
	canGoBack: boolean; // SceneChain only
}

SwapAnimationCustom

interface SwapAnimationCustom {
	enter: string; // e.g. "my-anim 400ms ease forwards"
	leave?: string;
	duration?: number; // Default: 400
}

LayerScene

| Prop | Type | Default | Description | | -------------------- | ------------------------------ | ---------------------- | ------------------------------------------------------------------ | | layers | LayerDef[] | required | Image and/or icon layer definitions | | mode | 'preview' \| 'edit' | 'preview' (or SpotsEditProvider) | Overrides SpotsEditProvider's global toggle if set | | onChange | (layers: LayerDef[]) => void | - | Fired after any edit-mode change | | onExport | (layers: LayerDef[]) => void | - | Fired by the Export button (+ clipboard copy) and by autoExportOnChange | | autoExportOnChange | boolean | false | Auto-fire onExport ~500ms after a change settles | | breakpoints | { mobile: number, tablet: number } | { mobile: 640, tablet: 1024 } | Breakpoint width thresholds (px), resolved from this LayerScene's own rendered width | | showChrome | boolean | true | Show the top toolbar + side layer panel. Set false when stacking with another LayerScene in the same viewport (e.g. SpotsEditProvider's backgroundLayers canvas does this internally) so their chrome doesn't collide - per-layer selection, handles, and readout still work either way |

LayerDef

A discriminated union - ImageLayerDef | IconLayerDef - switched on type.

ImageLayerDef

| Prop | Type | Default | Description | | ------------ | --------------------------- | -------- | ----------------------------------------------- | | id | string | required | Unique identifier | | type | 'image' | required | Discriminant | | src | string | required | Image URL | | alt | string | - | Alt text | | position | { x: number, y: number } | required | Position in % (0–100), from the layer's center | | width | number | required | Width in px | | height | number | required | Height in px | | rotate | number | 0 | Rotation in degrees | | zIndex | number | 0 | Stacking order | | dropShadow | string | - | CSS drop-shadow() filter value | | animation | LayerAnimation | 'none' | Preset or custom keyframes | | responsive | LayerResponsiveOverrides | - | Per-breakpoint overrides for position/width/height/rotate |

IconLayerDef

| Prop | Type | Default | Description | | ------------ | --------------------------- | -------- | ----------------------------------------------- | | id | string | required | Unique identifier | | type | 'icon' | required | Discriminant | | iconName | string | required | Any lucide-react export name, e.g. 'Coffee' | | position | { x: number, y: number } | required | Position in % (0–100), from the layer's center | | size | number | required | Icon box size in px | | rotate | number | 0 | Rotation in degrees | | color | string | - | Icon color | | zIndex | number | 0 | Stacking order | | animation | LayerAnimation | 'none' | Preset or custom keyframes | | responsive | LayerResponsiveOverrides | - | Per-breakpoint overrides for position/size/rotate |

LayerAnimation

A preset string, a configured preset, or custom keyframes:

type LayerAnimationPreset = 'none' | 'float' | 'pulse' | 'spin' | 'sway';

interface LayerAnimationConfig {
	type: LayerAnimationPreset;
	duration?: number;  // seconds - see the "Layer animations" defaults table above
	intensity?: number; // 0-1 - see the "Layer animations" defaults table above
}

interface LayerAnimationCustom {
	keyframes: string; // e.g. "my-anim 3s ease-in-out infinite"
	duration?: number; // Default: 3000
}

type LayerAnimation = LayerAnimationPreset | LayerAnimationConfig | LayerAnimationCustom;

SpotsEditProvider

Wraps your whole app (or a page) once and renders its own floating toggle - click it to flip edit mode for every LayerScene underneath at the same time. No manual switch, no threading a mode prop through each component. An individual LayerScene's own explicit mode prop still overrides it.

<SpotsEditProvider>
	<LayerScene layers={layers} />
</SpotsEditProvider>

// Controlled, with your own trigger elsewhere in the app:
<SpotsEditProvider editMode={isDevMode} showToggle={false}>
	<LayerScene layers={layers} />
</SpotsEditProvider>

| Prop | Type | Default | Description | | ------------------- | ---------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------- | | children | ReactNode | required | Your whole app or page - wrap it once | | defaultEditMode | boolean | false | Initial edit mode when uncontrolled | | editMode | boolean | - | Controlled edit mode - omit to let the provider manage its own state via the built-in toggle | | onEditModeChange | (editMode: boolean) => void | - | Fired whenever edit mode changes | | showToggle | boolean | true | Show the built-in floating toggle + Mobile/Tablet/Desktop switcher | | togglePosition | 'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' | 'bottom-right' | Corner for the floating toggle | | frameWidths | { mobile: number, tablet: number } | { mobile: 375, tablet: 768 } | Simulated device widths (px) for the Mobile/Tablet preview frame that wraps children | | backgroundLayers | LayerDef[] | - | Page-wide decorative image/icon layers, positioned in % over the full height of children - not confined to any single LayerScene's box | | onBackgroundLayersChange | (layers: LayerDef[]) => void | - | Fired after any edit to backgroundLayers | | onBackgroundExport | (layers: LayerDef[]) => void | - | Fired by the global panel's "Export background layers" button and by autoExportBackgroundOnChange | | autoExportBackgroundOnChange | boolean | false | Auto-fire onBackgroundExport ~500ms after a change settles |

Page-wide background layers

backgroundLayers renders as one overlay canvas spanning the full height of whatever children you wrap - place a layer at y: 60 to land it over whatever section happens to sit 60% down the page, regardless of which LayerScene (if any) is there:

<SpotsEditProvider
	backgroundLayers={bgLayers}
	onBackgroundLayersChange={setBgLayers}>
	<Hero />
	<Features />
	<Footer />
</SpotsEditProvider>

It's purely decorative in preview - pointer events pass through to your real page content underneath, so it never blocks buttons or links. While editing, it becomes draggable/selectable like any other layer, but renders with showChrome={false} internally (no competing toolbar/panel) so it doesn't collide with a LayerScene that happens to be on screen at the same time - only the shared floating panel (edit toggle, breakpoint switcher, background export) is global; each LayerScene's own toolbar and layer panel stay local to that instance.


License

MIT