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

starflock

v0.4.0

Published

Lightweight, zero-dependency canvas particle library. Composable forces, configurable everything.

Downloads

491

Readme

starflock

npm

Lightweight, zero-dependency canvas particle library. Composable forces, configurable everything.

npm install starflock

Quick start

import { World, drift, dampen, twinkle, mouseRepel } from 'starflock'

const world = new World({
  canvas: document.getElementById('bg'),
  nodeCount: 60,
  colors: ['#ffffff', '#cce8ff'],
  forces: [twinkle(), mouseRepel(), dampen(), drift()],
})

world.start()

Forces are just functions — compose them in any order, pass as an array. Each force runs every frame and mutates node velocities.


Recipes

Full-screen background

const world = new World({
  canvas,
  autoResize: true,
  pauseWhenHidden: true,
  pauseWhenOffscreen: true,
  nodeCount: 60,
  colors: ['#ffffff', '#cce8ff', '#fff4e0'],
  edgeMaxDist: 180,
  forces: [twinkle(), mouseRepel(), dampen(), drift()],
})
world.start()

Constellation with predefined edges

import { World, layouts, twinkle, dampen, mouseRepel } from 'starflock'

new World({
  canvas,
  layout: layouts.constellation('orion'),
  edges:  layouts.constellationEdges('orion'),
  colors: ['#ffffff', '#aad4ff', '#ffd2aa'],
  nodeSize: [1.5, 3.5],
  edgeMaxOpacity: 0.55,
  forces: [twinkle(), dampen(), mouseRepel()],
}).start()

Preset — one-liner

import { World, presets, twinkle, dampen, mouseRepel } from 'starflock'

new World({
  canvas,
  ...presets.orion(),
  forces: [twinkle(), dampen(), mouseRepel()],
}).start()

React

import { useStarflock } from 'starflock/react'
import { drift, dampen, twinkle, mouseRepel } from 'starflock'

export default function Background() {
  const ref = useStarflock({
    nodeCount: 60,
    colors: ['#ffffff', '#cce8ff'],
    forces: [twinkle(), mouseRepel(), dampen(), drift()],
  })

  return <canvas ref={ref} style={{ position: 'fixed', inset: 0, width: '100%', height: '100%' }} />
}

World options

Nodes

| Option | Type | Default | Description | |---|---|---|---| | nodeCount | number | 60 | Number of nodes — ignored when layout is set | | nodeSize | [min, max] | [0.8, 2.8] | Radius range in px | | colors | string[] | ['#ffffff'] | Node colors | | nodeShape | 'circle' \| 'diamond' \| 'star' \| 'cross' \| 'ring' | 'circle' | Node shape | | nodeRotation | boolean | false | Nodes rotate on their axis — visible on diamond/star | | nodeSizeDistribution | 'uniform' \| 'gaussian' \| 'weighted-small' | 'uniform' | Size sampling distribution | | nodeColorMode | 'random' \| 'by-size' \| 'sequential' \| 'gradient' \| 'by-position' | 'random' | How colors are assigned | | nodeSpawnRegion | 'full' \| 'center' \| 'edges' \| fn | 'full' | Where nodes spawn. fn(width, height) => {x, y} for custom regions | | layout | Layout \| Layout[] | — | Deterministic node placement — overrides nodeCount and nodeSpawnRegion |

Edges

| Option | Type | Default | Description | |---|---|---|---| | edges | Array<[number, number]> \| null | null | Predefined index pairs — when set, only these connections are drawn | | edgeMaxDist | number | 180 | Max distance for edge to appear — ignored when edges is set | | edgeMaxOpacity | number | 0.18 | Max edge opacity | | edgeWidth | number | 0.5 | Edge stroke width in px | | edgeColors | string[] | null | Edge colors — falls back to colors if not set | | edgeStyle | 'solid' \| 'dashed' \| 'gradient' | 'solid' | gradient interpolates between connected node colors | | edgeColorMode | 'alternate' \| 'source' \| 'target' \| fn | 'alternate' | fn(a, b, i, j) => color for custom logic | | edgeCurvature | number | 0 | 0 = straight, 1 = strong bezier curve | | maxEdgesPerNode | number \| null | null | Cap connections per node — prevents dense clusters | | minEdgesPerNode | number \| null | null | Guarantee minimum connections — draws faint long-range edges as fallback |

Glow

| Option | Type | Default | Description | |---|---|---|---| | glowOnLargeNodes | boolean | true | Adds radial halo to nodes above threshold | | glowThreshold | number | 2 | Minimum radius to trigger glow | | glowScale | number | 4 | Halo radius multiplier | | glowOpacity | number | 0.25 | Halo opacity |

Rendering

| Option | Type | Default | Description | |---|---|---|---| | blendMode | string | 'source-over' | Canvas globalCompositeOperation'screen' makes overlapping nodes glow brighter | | renderOrder | 'edges-first' \| 'nodes-first' | 'edges-first' | Draw order | | background | string \| null | null | Background fill color — null for transparent | | pixelRatio | number \| 'auto' | 'auto' | HiDPI/Retina pixel ratio |

Performance

| Option | Type | Default | Description | |---|---|---|---| | pauseWhenHidden | boolean | true | Pause RAF when tab is not visible | | pauseWhenOffscreen | boolean | false | Pause RAF when canvas is scrolled out of view | | autoResize | boolean | true | Fit canvas to window on resize — set to false when managing canvas size yourself | | maxEdgesPerFrame | number \| null | null | Hard cap on edges drawn per frame — useful for very high node counts | | spatialIndex | boolean | false | Use a QuadTree for edge queries — O(n log n) instead of O(n²), worth enabling above ~200 nodes |

Callbacks

| Option | Type | Description | |---|---|---| | onFrame | (nodes, context) => void | Called every frame after rendering | | onNodeHover | (node) => void | Called when cursor enters a node's hitbox | | onNodeLeave | (node) => void | Called when cursor leaves a node's hitbox | | onNodeClick | (node) => void | Called when a node is clicked |


Layouts & Presets

A Layout is a function (width, height) => Array<{x, y}> that returns deterministic node positions. Pass one or an array to combine multiple layouts.

layouts.ring(opts?)

Places nodes evenly on a circle.

| Param | Default | | |---|---|---| | count | 48 | Number of nodes | | radius | 0.38 | Radius relative to Math.min(width, height) | | cx | 0.5 | Center x, relative to width | | cy | 0.5 | Center y, relative to height |

layouts.constellation(name, opts?)

Places nodes at named star positions. Coordinates are scaled to the canvas.

Available names: 'orion' · 'big-dipper' · 'cassiopeia' · 'crux' · 'cygnus' · 'leo'

| Param | Default | | |---|---|---| | scale | 0.7 | Size relative to Math.min(width, height) | | cx | 0.5 | Center x, relative to width | | cy | 0.5 | Center y, relative to height |

layouts.constellationEdges(name)

Returns the predefined edge pairs for a constellation — index pairs into the node array produced by layouts.constellation(name).

new World({
  canvas,
  layout: layouts.constellation('cassiopeia'),
  edges:  layouts.constellationEdges('cassiopeia'),
  ...
})

Combining layouts

Pass an array of layouts to merge multiple constellations. Offset the edge indices by the cumulative node count of previous layouts:

const orionEdges  = layouts.constellationEdges('orion')
const dipperEdges = layouts.constellationEdges('big-dipper').map(([i, j]) => [i + 7, j + 7])

new World({
  canvas,
  layout: [
    layouts.constellation('orion',      { cx: 0.3 }),
    layouts.constellation('big-dipper', { cx: 0.7 }),
  ],
  edges: [...orionEdges, ...dipperEdges],
  ...
})

Custom layouts

Any function (width, height) => Array<{x, y}> works:

function circle(count = 40) {
  return (width, height) => Array.from({ length: count }, (_, i) => {
    const angle = (i / count) * Math.PI * 2
    return {
      x: width  / 2 + Math.cos(angle) * width  * 0.35,
      y: height / 2 + Math.sin(angle) * height * 0.35,
    }
  })
}

new World({ canvas, layout: circle(40), forces: [twinkle(), dampen()] }).start()

Presets

Presets are plain objects — spread them into the constructor and override anything:

import { presets } from 'starflock'

presets.orion()      // layout + edges + colors for Orion
presets.bigDipper()  // layout + edges + colors for the Big Dipper

// override individual options
new World({ canvas, ...presets.orion(), edgeMaxOpacity: 0.8 }).start()

Forces

Forces are plain functions — (nodes, context) => void. The context object provides { time, mouse, scrollY, width, height }. Combine freely.

drift({ maxSpeed })

Caps node velocity — prevents runaway acceleration. Use as the last force in your chain.

| Param | Default | | |---|---|---| | maxSpeed | 0.08 | Maximum speed in px/frame |

dampen({ factor })

Multiplies all velocities by factor each frame — simulates friction.

| Param | Default | | |---|---|---| | factor | 0.99 | 0.95 = heavy friction, 0.999 = near frictionless |

twinkle({ minBrightness, variance })

Oscillates node opacity using a per-node sine wave.

| Param | Default | | |---|---|---| | minBrightness | 0.5 | Minimum alpha | | variance | 0.5 | Oscillation amplitude |

mouseRepel({ mode, radius, strength, fn })

Cursor interaction.

| Param | Default | | |---|---|---| | mode | 'repel' | 'repel' · 'attract' · 'orbit' · 'custom' | | radius | 120 | Influence radius in px | | strength | 0.012 | Force multiplier | | fn | — | fn(node, mouse, context) when mode: 'custom' |

gravity({ x, y, strength })

Pulls all nodes toward a fixed point. Values 0..1 are relative to canvas size.

| Param | Default | | |---|---|---| | x | 0.5 | Target x — 0.5 = center | | y | 0.5 | Target y | | strength | 0.0002 | Force magnitude |

attract({ x, y, radius, strength })

Like gravity but only affects nodes within radius. Has a dead zone at the center — combine with nodeRepel to prevent clustering.

| Param | Default | | |---|---|---| | x | 0.5 | Target x | | y | 0.5 | Target y | | radius | 200 | Influence radius in px | | strength | 0.001 | Force magnitude |

nodeRepel({ radius, strength })

Nodes push each other away — simulates charged particles. O(n²), keep radius small.

| Param | Default | | |---|---|---| | radius | 60 | Repulsion radius | | strength | 0.003 | Force magnitude |

wind({ angle, strength, gust })

Constant directional force with optional sinusoidal gusting.

| Param | Default | | |---|---|---| | angle | 0 | Direction in radians | | strength | 0.001 | Base force magnitude | | gust | 0 | Gust amplitude |

noise({ scale, strength, speed })

Moves nodes along a slowly shifting 2D vector field. Nearby nodes flow in the same direction — organic, non-random.

| Param | Default | | |---|---|---| | scale | 0.004 | Field scale — small = broad sweeping currents | | strength | 0.001 | Force magnitude | | speed | 0.0003 | How fast the field shifts over time |

scrollDrift({ mode, strength })

Reacts to world.scrollY. In autoResize mode this tracks window.scrollY automatically.

| Param | Default | | |---|---|---| | mode | 'rotate' | 'rotate' · 'wave' · 'scatter' · 'custom' | | strength | 1.0 | Effect multiplier | | fn | — | fn(node, delta, context) when mode: 'custom' |


Custom forces

Any function matching (nodes, context) => void works as a force:

function pulse() {
  return (nodes, { time }) => {
    const scale = Math.sin(time * 0.001)
    for (const node of nodes) {
      node.vx += (node.x - 400) * scale * 0.0001
      node.vy += (node.y - 300) * scale * 0.0001
    }
  }
}

new World({ canvas, forces: [pulse(), dampen(), drift()] })

Shapes

circle · diamond · star · cross · ring

Custom shape function: (ctx, x, y, r) => void

import { World } from 'starflock'

function triangle(ctx, x, y, r) {
  ctx.beginPath()
  ctx.moveTo(x, y - r)
  ctx.lineTo(x + r, y + r)
  ctx.lineTo(x - r, y + r)
  ctx.closePath()
  ctx.fill()
}

new World({ canvas, nodeShape: triangle })

API

world.start()

Begins the render loop and registers event listeners.

world.stop()

Cancels the render loop and removes all event listeners.

world.update(options)

Live-updates any option without restarting. Node positions and velocities are preserved.

world.update({ colors: ['#ff0000'], edgeMaxOpacity: 0.4 })

world.resize()

Recomputes canvas dimensions and recreates nodes. Call this when managing canvas size manually (autoResize: false).

world.scrollY

Set directly when managing scroll manually (e.g. in a bounded canvas):

window.addEventListener('scroll', () => { world.scrollY = window.scrollY })