motion study - free to use

Nine States of one line

Nine States of one line

A process indicator drawn as a single continuous stroke. It never changes form, only behvaiour - the dame line listens, diverges, converges, and settles.

A process indicator drawn as a single continuous stroke. It never changes form, only behvaiour - the dame line listens, diverges, converges, and settles.

01
IdleNothing asked yet. The line breathes.
02
ListeningTaking input. Amplitude follows the voice.
03
GatheringPulling material inward, turn by turn.
04
DivergingThree directions held open at once.
05
ThinkingA knot turning over on itself.
06
ConvergingA coil closing on the answer.
07
RefiningSmall corrections at high frequency.
08
TestingCrossing back over the same path.
09
ShippedSettled. One point still travelling.
01
IdleNothing asked yet. The line breathes.
02
ListeningTaking input. Amplitude follows the voice.
03
GatheringPulling material inward, turn by turn.
04
DivergingThree directions held open at once.
05
ThinkingA knot turning over on itself.
06
ConvergingA coil closing on the answer.
07
RefiningSmall corrections at high frequency.
08
TestingCrossing back over the same path.
09
ShippedSettled. One point still travelling.

Playground

Pick a state, size it, then take the code — web, React Native, or SwiftUI.

State
Size
Weight
Speed 1.00×
ThinkingA knot turning over on itself.
Thinking
// Thread — Thinking
// Single-stroke process indicator. React + Canvas, no dependencies. MIT.

import { useEffect, useRef } from "react"

const TAU = Math.PI * 2
const SEGMENTS = 150
const CONFIG = { closed: true, head: 0.3, tail: 0.22, base: 0.2 }

function curve(u, t) {
  const a = u * TAU
  const x = (Math.sin(a) + 2 * Math.sin(2 * a)) / 3
  const y = (Math.cos(a) - 2 * Math.cos(2 * a)) / 3
  const p = t * 0.45
  return [x * Math.cos(p) - y * Math.sin(p), x * Math.sin(p) + y * Math.cos(p)]
}

export default function Thread({
  size = 96,
  color = "#F2F3F3",
  weight = 1.4,
  speed = 1,
}) {
  const ref = useRef(null)

  useEffect(() => {
    const canvas = ref.current
    const ctx = canvas.getContext("2d")
    const dpr = Math.min(window.devicePixelRatio || 1, 2)
    canvas.width = size * dpr
    canvas.height = size * dpr
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)

    let raf = 0
    const t0 = performance.now()

    const frame = (now) => {
      const t = ((now - t0) / 1000) * speed
      const R = (size / 2) * 0.78
      ctx.clearRect(0, 0, size, size)
      ctx.save()
      ctx.translate(size / 2, size / 2)
      ctx.lineCap = "round"
      ctx.strokeStyle = color

      const pts = []
      for (let i = 0; i <= SEGMENTS; i++) {
        const [x, y] = curve(i / SEGMENTS, t)
        pts.push([x * R, y * R])
      }

      // bloom: the whole path once, wide and faint
      ctx.globalAlpha = 0.07
      ctx.lineWidth = weight * 3.4
      ctx.beginPath()
      ctx.moveTo(pts[0][0], pts[0][1])
      for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1])
      if (CONFIG.closed) ctx.closePath()
      ctx.stroke()

      // the stroke, with a comet head running along it
      const head = (t * CONFIG.head) % 1
      const last = CONFIG.closed ? SEGMENTS : SEGMENTS - 1
      for (let i = 0; i < last; i++) {
        let d = head - i / SEGMENTS
        if (d < 0) d += 1
        const boost = Math.exp(-d / CONFIG.tail)
        ctx.globalAlpha = Math.min(1, CONFIG.base + (1 - CONFIG.base) * boost)
        ctx.lineWidth = weight * (0.6 + 0.85 * boost)
        const a = pts[i]
        const b = pts[(i + 1) % pts.length]
        ctx.beginPath()
        ctx.moveTo(a[0], a[1])
        ctx.lineTo(b[0], b[1])
        ctx.stroke()
      }
      ctx.restore()
      raf = requestAnimationFrame(frame)
    }

    raf = requestAnimationFrame(frame)
    return () => cancelAnimationFrame(raf)
  }, [size, color, weight, speed])

  return <canvas ref={ref} style={{ width: size, height: size }} />
}
Thread.jsxReact 17+ · canvas 2D · no packagesMIT licence — no attribution required

Playground

Pick a state, size it, then take the code — web, React Native, or SwiftUI.

State
Size
Weight
Speed 1.00×
ThinkingA knot turning over on itself.
Thinking
// Thread — Thinking
// Single-stroke process indicator. React + Canvas, no dependencies. MIT.

import { useEffect, useRef } from "react"

const TAU = Math.PI * 2
const SEGMENTS = 150
const CONFIG = { closed: true, head: 0.3, tail: 0.22, base: 0.2 }

function curve(u, t) {
  const a = u * TAU
  const x = (Math.sin(a) + 2 * Math.sin(2 * a)) / 3
  const y = (Math.cos(a) - 2 * Math.cos(2 * a)) / 3
  const p = t * 0.45
  return [x * Math.cos(p) - y * Math.sin(p), x * Math.sin(p) + y * Math.cos(p)]
}

export default function Thread({
  size = 96,
  color = "#F2F3F3",
  weight = 1.4,
  speed = 1,
}) {
  const ref = useRef(null)

  useEffect(() => {
    const canvas = ref.current
    const ctx = canvas.getContext("2d")
    const dpr = Math.min(window.devicePixelRatio || 1, 2)
    canvas.width = size * dpr
    canvas.height = size * dpr
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)

    let raf = 0
    const t0 = performance.now()

    const frame = (now) => {
      const t = ((now - t0) / 1000) * speed
      const R = (size / 2) * 0.78
      ctx.clearRect(0, 0, size, size)
      ctx.save()
      ctx.translate(size / 2, size / 2)
      ctx.lineCap = "round"
      ctx.strokeStyle = color

      const pts = []
      for (let i = 0; i <= SEGMENTS; i++) {
        const [x, y] = curve(i / SEGMENTS, t)
        pts.push([x * R, y * R])
      }

      // bloom: the whole path once, wide and faint
      ctx.globalAlpha = 0.07
      ctx.lineWidth = weight * 3.4
      ctx.beginPath()
      ctx.moveTo(pts[0][0], pts[0][1])
      for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1])
      if (CONFIG.closed) ctx.closePath()
      ctx.stroke()

      // the stroke, with a comet head running along it
      const head = (t * CONFIG.head) % 1
      const last = CONFIG.closed ? SEGMENTS : SEGMENTS - 1
      for (let i = 0; i < last; i++) {
        let d = head - i / SEGMENTS
        if (d < 0) d += 1
        const boost = Math.exp(-d / CONFIG.tail)
        ctx.globalAlpha = Math.min(1, CONFIG.base + (1 - CONFIG.base) * boost)
        ctx.lineWidth = weight * (0.6 + 0.85 * boost)
        const a = pts[i]
        const b = pts[(i + 1) % pts.length]
        ctx.beginPath()
        ctx.moveTo(a[0], a[1])
        ctx.lineTo(b[0], b[1])
        ctx.stroke()
      }
      ctx.restore()
      raf = requestAnimationFrame(frame)
    }

    raf = requestAnimationFrame(frame)
    return () => cancelAnimationFrame(raf)
  }, [size, color, weight, speed])

  return <canvas ref={ref} style={{ width: size, height: size }} />
}
Thread.jsxReact 17+ · canvas 2D · no packagesMIT licence — no attribution required