Skip to content

FreeIn reviewv1.0.0

Strands

Lines of light in four arrangements: a ribbon, sines that meet at a node, bundles of fine strands, or a veil.
npx shadcn@latest add @vibebox/strands

Controls

1
1
<Strands />

About

Each strand is y = f(x) of two or three sines (or x = f(y) for a veil), with its own phase that drifts. A pixel's distance to the curve goes through a Gaussian for the core and two broader falloffs for the halo; colour is a three-stop ramp along the strand's own parameter, mixed toward the core colour where it is brightest. The weave mode lets amplitude grow with the distance from a node, so every strand meets at one point and a bloom sits there. The pointer pulls the curves toward its height with a Gaussian in x. All of it adds to a dark base, so crossings brighten on their own.

Use it when

  • A product or launch page wants a calm, premium abstract hero.
  • A brand wants light as its identity: music, audio, motion, fintech.
  • A deck or report cover wants a few lines that suggest movement.

Skip it when

  • The brand is warm and tactile: use Satin or Liquid Ink.
  • The page needs a literal place: use Ocean, Terrain or City Night.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/strands
  3. 3Use it:
    import { Strands } from "@/components/vibebox/strands";
    
    <Strands preset="pulse" className="min-h-[560px] rounded-xl">
      <div className="p-12 text-white">
        <h1 className="text-4xl font-semibold">Light, in a few lines.</h1>
      </div>
    </Strands>

Code

3 files
components/vibebox/strands/index.tsx
/**
 * VibeBox Motion · Strands
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { Strands, type StrandsProps, type StrandsColor, type StrandsValue } from "./strands.client";
export { PRESET_NAMES as STRANDS_PRESETS, presets as strandsPresets, type StrandsPreset } from "./strands.shader";
components/vibebox/strands/strands.client.tsx
/**
 * VibeBox Motion · Strands
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import type { ReactNode } from "react";
import { BackdropCanvas, type PosterSpec } from "@/lib/vibebox/backdrop-canvas";
import { strandsPiece, presets, type StrandsPreset } from "./strands.shader";

export type StrandsColor = "base" | "a" | "b" | "c" | "core";
export type StrandsValue = "mode" | "count" | "glow" | "width" | "sway";

export type StrandsProps = {
  /** What sits on the scene: a heading, a call to action, a whole section. */
  children?: ReactNode;
  /** A named look: `pulse`, `weave`, `silk`, `veil`, `ember`. */
  preset?: StrandsPreset;
  /** Overrides single palette colours with any CSS colour. */
  colors?: Partial<Record<StrandsColor, string>>;
  /** Multiplies the preset's speed (1 keeps it, 0 holds the scene still). */
  speed?: number;
  /** Multiplies the preset's brightness. */
  intensity?: number;
  /** The pointer and touch move the scene. */
  interactive?: boolean;
  /** Accessible name; leave it out when the scene is only a background. */
  label?: string;
  /** Hold one still frame until this is false (for a gallery card). */
  still?: boolean;
  className?: string;
} & Partial<Record<StrandsValue, number>>;

const POSTER: PosterSpec = { type: "linear", from: "base", to: "base", angle: 180, blobs: [{ color: "a", at: [30, 52], size: 46 }, { color: "c", at: [74, 48], size: 40 }] };

export function Strands({ children, preset, colors, speed, intensity, interactive = true, label, still, className, mode, count, glow, width, sway }: StrandsProps) {
  return (
    <BackdropCanvas
      slug="strands"
      piece={strandsPiece}
      presets={presets}
      fallback="pulse"
      preset={preset}
      colors={colors}
      values={{ mode, count, glow, width, sway }}
      speed={speed}
      intensity={intensity}
      poster={POSTER}
      scale={0.5}
      stillTime={4}
      still={still}
      interactive={interactive}
      label={label}
      className={className}
    >
      {children}
    </BackdropCanvas>
  );
}
components/vibebox/strands/strands.shader.ts
/**
 * VibeBox Motion · Strands
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import { compose, createScenePiece, type ScenePreset } from "@/lib/vibebox/engine/backdrops";

/*
 * Lines of light, in four arrangements picked by `mode`: a single soft ribbon (wave), a handful of
 * sines that pinch to one bright node and fan out again (weave), bundles of fine strands that cross
 * the frame (bundles) and vertical veils with a few brighter threads (veil). Each strand is a curve
 * built from two or three sines; a pixel's distance to it gives a thin bright core and a wide
 * halo, and colour runs along the length through a three-stop ramp, so the brightest part of a
 * ribbon is never the same as its ends. Everything is additive on a dark base, which is why it
 * glows. The pointer pulls the nearest strands toward it.
 */

const SCENE = /* glsl */ `
uniform vec3 uBase;
uniform vec3 uA;
uniform vec3 uB;
uniform vec3 uC;
uniform vec3 uCore;
uniform float uMode;
uniform float uCount;
uniform float uGlow;
uniform float uWidth;
uniform float uSway;
uniform float uSpeed;
uniform float uIntensity;

float T;

float sq(float x) { return x * x; }

/* A thin bright core and a soft halo for a distance d from a curve. */
float stroke(float d, float w, float halo) {
  float core = exp(-sq(d / w));
  float soft = exp(-abs(d) / (w * 8.0)) * 0.16 + exp(-sq(d / (w * 22.0))) * 0.07;
  return core + soft * halo;
}

/* The colour along a strand, from one end to the other. */
vec3 along(float t) {
  t = clamp(t, 0.0, 1.0);
  return t < 0.5 ? mix(lin(uA), lin(uB), t * 2.0) : mix(lin(uB), lin(uC), (t - 0.5) * 2.0);
}

void main() {
  vec2 uv = centred(gl_FragCoord.xy);
  T = uTime * uSpeed;
  float aspect = uResolution.x / uResolution.y;
  vec2 m = (uPointer - 0.5) * vec2(aspect, 1.0);
  float w = 0.0034 * uWidth;
  vec3 col = lin(uBase);
  int mode = int(uMode + 0.5);
  int n = int(clamp(uCount, 1.0, 12.0) + 0.5);

  if (mode == 0) {
    // A single ribbon: two sines, a slow breathing brightness along its length.
    for (int i = 0; i < 3; i++) {
      float fi = float(i);
      if (fi > 0.5 && n < 2) break;
      float off = (fi - 0.5 * float(min(n, 3) - 1)) * 0.035;
      float x = uv.x;
      float y = 0.0 + off + (0.11 * sin(x * 2.4 + T * 0.35 + fi * 0.6) + 0.06 * sin(x * 4.9 - T * 0.5 + 1.3 + fi)) * uSway;
      y += (m.y - y) * 0.2 * exp(-sq((x - m.x) * 2.0)) * (0.3 + uHover);
      float d = uv.y - y;
      float env = pow(0.5 + 0.5 * cos(x * 3.1 + T * 0.22 + fi), 1.6);
      float t = 0.5 + 0.5 * sin(x * 1.7 - T * 0.12 + fi * 0.4);
      vec3 c = mix(along(t), lin(uCore), 0.2 + 0.5 * env);
      col += c * stroke(d, w * 1.6, uGlow * 1.5) * (0.25 + 1.2 * env) * 0.85;
      // The glow pools above and below it, wider than the line itself.
      col += along(t) * exp(-sq(d / 0.06)) * env * 0.38 * uGlow;
    }
  } else if (mode == 1) {
    // Sines that meet at one node: the amplitude grows with the distance from the centre.
    float nodeX = 0.04 * sin(T * 0.15);
    for (int i = 0; i < 12; i++) {
      if (i >= n) break;
      float fi = float(i);
      float f = 2.2 + 0.55 * fi + 0.4 * sin(fi * 2.1);
      float ph = fi * 1.9 + T * (0.3 + 0.05 * fi);
      float amp = (0.22 - 0.012 * fi) * (0.4 + 0.6 * h1(i + 3)) * uSway;
      float x = uv.x - nodeX;
      float y = amp * sin(f * x + ph) * pow(abs(x) * 1.6, 0.85) * (1.0 + 0.4 * sin(x * 1.3 + fi));
      y += (m.y - y) * 0.12 * exp(-sq((x - m.x) * 3.0)) * (0.3 + uHover) * 0.5;
      float d = uv.y - y;
      vec3 c = mix(along(0.5 + 0.5 * sin(fi * 1.3 + T * 0.1)), lin(uCore), 0.35);
      col += c * stroke(d, w, uGlow) * 0.9;
    }
    // The node: where they all meet, a bloom.
    float r = length(uv - vec2(nodeX, 0.0));
    col += lin(uCore) * (exp(-r * 22.0) * 0.9 + exp(-r * 6.0) * 0.18) * uGlow;
  } else if (mode == 2) {
    // Bundles: ribbons of fine strands, each crossing the frame at its own angle.
    for (int b = 0; b < 3; b++) {
      float fb = float(b);
      float ang = fb < 0.5 ? -0.3 : (fb < 1.5 ? 0.12 : 1.02);
      vec2 p = rot(ang) * (uv + vec2(0.0, (fb - 1.0) * 0.14));
      float centre = (0.13 * sin(p.x * 1.9 + T * (0.18 + 0.04 * fb) + fb * 2.0) + 0.07 * sin(p.x * 4.1 - T * 0.25 + fb)) * uSway;
      float spread = (0.016 + 0.014 * (0.5 + 0.5 * sin(p.x * 2.2 + fb * 1.7 + T * 0.1))) * (0.7 + 0.5 * float(n) / 8.0);
      float bright = 0.55 + 0.45 * sin(p.x * 1.4 + fb * 2.6 + T * 0.14);
      for (int k = 0; k < 10; k++) {
        if (k >= n) break;
        float fk = float(k) - 0.5 * float(n - 1);
        float y = centre + fk * spread * (1.0 + 0.5 * sin(p.x * 1.1 + float(k) * 0.4));
        float d = p.y - y;
        float t = float(k) / float(max(n - 1, 1));
        vec3 c = mix(lin(uCore), along(0.15 + 0.7 * t), 0.35 + 0.5 * fract(fb * 0.5 + t));
        col += c * stroke(d, w * 0.8, uGlow * 0.4) * (0.3 + 0.8 * bright) * 0.6;
      }
    }
  } else {
    // Veils: a curtain of vertical threads, a few bright and many faint, over wide soft columns.
    for (int i = 0; i < 12; i++) {
      if (i >= n + 2) break;
      float fi = float(i);
      float x0 = (fi / float(n + 1) - 0.5) * 1.5 * aspect * 0.7 + 0.05 * sin(fi * 3.1);
      float sway = (0.06 * sin(uv.y * 2.3 + T * 0.2 + fi * 1.7) + 0.035 * sin(uv.y * 5.0 - T * 0.3 + fi)) * uSway;
      float x = x0 + sway + (uv.y * 0.18 * sin(fi * 0.9 + 1.0));
      float d = uv.x - x;
      float rank = h1(i + 7);
      float bright = 0.25 + 1.2 * pow(rank, 3.0);
      vec3 c = along(0.2 + 0.7 * rank);
      col += c * stroke(d, w * 0.7, 0.0) * bright;
      col += c * exp(-sq(d / (0.04 + 0.05 * rank))) * (0.1 + 0.55 * rank) * uGlow * (0.55 + 0.45 * sin(uv.y * 1.6 + T * 0.18 + fi));
    }
  }

  col *= uIntensity;
  col *= 1.0 - 0.35 * smoothstep(0.55, 1.2, length(uv * vec2(0.8, 1.05)));
  fragColor = vec4(finish(col, gl_FragCoord.xy), 1.0);
}
`;

export const fragment = compose(SCENE);

export const PRESET_NAMES = ["pulse", "weave", "silk", "veil", "ember"] as const;
export type StrandsPreset = (typeof PRESET_NAMES)[number];

export const presets: Record<StrandsPreset, ScenePreset> = {
  pulse: {
    colors: { base: "#0d0b14", a: "#ff5fb3", b: "#9fb4ff", c: "#ff4f8a", core: "#f4ecff" },
    values: { mode: 0, count: 2, glow: 1, width: 1, sway: 1, speed: 0.8, intensity: 1 },
  },
  weave: {
    colors: { base: "#0f0b1a", a: "#8f7bff", b: "#c7b8ff", c: "#7a5cff", core: "#ffffff" },
    values: { mode: 1, count: 7, glow: 1, width: 1, sway: 1, speed: 0.8, intensity: 1 },
  },
  silk: {
    colors: { base: "#0d0b12", a: "#d86bff", b: "#ffffff", c: "#a64dff", core: "#ffffff" },
    values: { mode: 2, count: 7, glow: 1, width: 1, sway: 1, speed: 0.7, intensity: 1 },
  },
  veil: {
    colors: { base: "#0a0c1a", a: "#3a63ff", b: "#6f9bff", c: "#c8d6ff", core: "#ffffff" },
    values: { mode: 3, count: 6, glow: 1.1, width: 1, sway: 1, speed: 0.6, intensity: 1.05 },
  },
  ember: {
    colors: { base: "#0f0806", a: "#ff7a2e", b: "#ffd07a", c: "#ff4a3a", core: "#fff1d6" },
    values: { mode: 2, count: 6, glow: 1.1, width: 1.1, sway: 1.1, speed: 0.7, intensity: 1.05 },
  },
};

export const strandsPiece = createScenePiece("strands", fragment);

Props

PropTypeDefaultDescription
childrenReactNode—What sits on the scene: a heading, a call to action, a whole section.
preset"pulse" | "weave" | "silk" | "veil" | "ember""pulse"The look: pulse, weave, silk, veil, ember.
colors{ base?: string; a?: string; b?: string; c?: string; core?: string }—Overrides single palette colours with any CSS colour: base (the dark ground), a (the colour at the start of each strand), b (the colour in the middle), c (the colour at the end), core (the bright core and the node).
speednumber1Multiplies the preset's speed; 0 holds the scene still.
intensitynumber1Multiplies the preset's brightness, 0.5 to 1.4.
modenumber00 ribbon, 1 weave, 2 bundles, 3 veil (0 to 3; each look sets its own).
countnumber2How many strands are drawn (1 to 12; each look sets its own).
glownumber1How much light spreads around each strand (0 to 2; each look sets its own).
widthnumber1How thick the cores are (0.4 to 2; each look sets its own).
swaynumber1How far the strands wander (0.2 to 2; each look sets its own).
interactivebooleantrueThe nearest strands lean toward the pointer, a little further when it is over the scene.
labelstring—Accessible name; leave it out when the scene is only a background.
stillbooleanfalseHold one composed frame until false (a gallery card that plays on hover).
classNamestring—Merged with cn(); give it a size and a radius.

Accessibility and motion

Keyboard
Not focusable; content on it keeps its own focus order.
Screen readers
Decorative by default; with label it is an image with that name.
Touch
A finger pulls the nearest strands toward it like the cursor does; scrolling is never blocked.
Reduced motion
One still frame of the lines; they do not drift.

Edge cases and limits

  • The node and the crossings are the brightest parts: keep text off them.
  • At twelve strands on a small phone the cores nearly merge; the defaults are tuned for hero size.
  • It is almost all black: on a light page wrap it in a rounded card rather than full-bleed.
  • Many thin cyan, violet and magenta lines of light flowing across a dark background in one wave

    Dozens of thin lines of light riding one slow wave, bunching where they cross, with a glow that follows the pointer.

    Backgrounds · WebGL2

  • Warm amber shafts of light fanning down through haze with dust in the beams

    Rays

    Free

    Shafts of light through haze from a source off the frame, with dust drifting in the beams and optional window blinds.

    Backgrounds · WebGL2

  • Green and violet aurora curtains over a mountain ridge and stars

    Aurora

    Free

    Curtains of northern light over a night sky and a mountain ridge, drawn toward the pointer.

    Backgrounds · WebGL2

↑ ↓ to move↵ to openesc to close