Rocket Starfield Pro

Rocket is currently in beta.

A customizable starfield effect using HTML5 Canvas and reactive signals. Move your mouse over the starfield to control the center position.

Demo
x: , y:

Explanation #

The outer demo owns the interactive controls and passes the current values in as props. The component itself mounts the canvas from refs.canvas in onFirstRender(), then keeps the animation loop and resize observer internal.

Usage Example #

1<div data-signals='{"centerX":50,"centerY":50,"speed":50,"starCount":500}'>
2    <star-field
3        data-attr:center-x="$centerX"
4        data-attr:center-y="$centerY"
5        data-attr:speed="$speed"
6        data-attr:star-count="$starCount"
7    ></star-field>
8</div>

Rocket Component #

  1import { rocket } from 'datastar'
  2
  3rocket('star-field', {
  4  mode: 'light',
  5  props: ({ number }) => ({
  6    starCount: number.min(1).default(500),
  7    speed: number.clamp(1, 100).default(50),
  8    centerX: number.clamp(0, 100).default(50),
  9    centerY: number.clamp(0, 100).default(50),
 10  }),
 11  onFirstRender: ({ refs, cleanup, host, observeProps, props }) => {
 12    let aid = 0
 13    let ro
 14    let stars = []
 15    let last = 0
 16    let count = props.starCount
 17    let canvas
 18    let ctx
 19    let bg = '#000'
 20    let strokeStyles = []
 21
 22    const reset = () => {
 23      if (!canvas) return
 24      const {
 25        clientWidth: width = innerWidth,
 26        clientHeight: height = innerHeight,
 27      } = canvas.parentElement ?? {}
 28      canvas.width = width
 29      canvas.height = height
 30      stars = Array.from({ length: props.starCount }, () => {
 31        const x = Math.random() * width,
 32          y = Math.random() * height
 33        return { fx: x, fy: y, tx: x, ty: y, b: (Math.random() * 100) | 0 }
 34      })
 35    }
 36
 37    const animate = (now = 0) => {
 38      if (!canvas || !ctx) return
 39      const { width, height } = canvas
 40      const centerX = (width * props.centerX) / 100
 41      const centerY = (height * props.centerY) / 100
 42      const dt = last ? (now - last) / 1000 : 0
 43      const velocity = (1 + (8 * (props.speed - 1)) / 99) * dt * 0.5
 44      const max = Math.min(width, height) * 0.1
 45      const buckets = {}
 46
 47      last = now
 48      ctx.clearRect(0, 0, width, height)
 49      ctx.fillStyle = bg
 50      ctx.fillRect(0, 0, width, height)
 51
 52      for (const star of stars) {
 53        star.fx = star.tx
 54        star.fy = star.ty
 55        star.tx += Math.max(-max, Math.min(max, (star.tx - centerX) * velocity))
 56        star.ty += Math.max(-max, Math.min(max, (star.ty - centerY) * velocity))
 57        star.b = Math.min(100, star.b + 1)
 58
 59        if (star.fx < 0 || star.fx > width || star.fy < 0 || star.fy > height) {
 60          star.tx = star.fx = Math.random() * width
 61          star.ty = star.fy = Math.random() * height
 62          star.b = (Math.random() * 100) | 0
 63          continue
 64        }
 65
 66        const bucket = (star.b / 10) | 0
 67        ;(buckets[bucket] ??= new Path2D()).moveTo(star.fx, star.fy)
 68        buckets[bucket].lineTo(star.tx, star.ty)
 69      }
 70
 71      ctx.lineWidth = 1
 72      for (const bucket in buckets) {
 73        ctx.strokeStyle = strokeStyles[bucket]
 74        ctx.stroke(buckets[bucket])
 75      }
 76
 77      aid = requestAnimationFrame(animate)
 78    }
 79
 80    canvas = refs.canvas
 81    ctx = canvas?.getContext('2d', {
 82      alpha: true,
 83      antialias: true,
 84      optimizeSpeed: true,
 85      willReadFrequently: false,
 86    })
 87    if (!canvas || !ctx) return
 88
 89    canvas.removeAttribute('width')
 90    canvas.removeAttribute('height')
 91
 92    const style = getComputedStyle(host)
 93    bg = style.backgroundColor
 94    strokeStyles = Array.from(
 95      { length: 10 },
 96      (_, i) => `color-mix(in srgb, ${style.color}, ${bg} ${i * 10}%)`,
 97    )
 98
 99    reset()
100    animate()
101    ro = new ResizeObserver(reset)
102    ro.observe(canvas.parentElement || canvas)
103
104    observeProps(() => {
105      if (props.starCount === count) return
106      count = props.starCount
107      reset()
108    }, 'starCount')
109
110    cleanup(() => {
111      cancelAnimationFrame(aid)
112      ro?.disconnect()
113    })
114  },
115  render: ({ html }) => html`
116    <style>
117      .rocket-starfield {
118        display: block;
119        width: 100%;
120        height: 100%;
121      }
122
123      canvas {
124        display: block;
125        width: 100%;
126        height: 100%;
127      }
128    </style>
129
130    <div class="rocket-starfield">
131      <canvas data-ref:canvas data-ignore-morph></canvas>
132    </div>
133  `,
134})