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
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/strands - 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 filescomponents/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
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | 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). |
| speed | number | 1 | Multiplies the preset's speed; 0 holds the scene still. |
| intensity | number | 1 | Multiplies the preset's brightness, 0.5 to 1.4. |
| mode | number | 0 | 0 ribbon, 1 weave, 2 bundles, 3 veil (0 to 3; each look sets its own). |
| count | number | 2 | How many strands are drawn (1 to 12; each look sets its own). |
| glow | number | 1 | How much light spreads around each strand (0 to 2; each look sets its own). |
| width | number | 1 | How thick the cores are (0.4 to 2; each look sets its own). |
| sway | number | 1 | How far the strands wander (0.2 to 2; each look sets its own). |
| interactive | boolean | true | The nearest strands lean toward the pointer, a little further when it is over the scene. |
| label | string | — | Accessible name; leave it out when the scene is only a background. |
| still | boolean | false | Hold one composed frame until false (a gallery card that plays on hover). |
| className | string | — | 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
labelit 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.
Related

Flow Lines
FreeDozens of thin lines of light riding one slow wave, bunching where they cross, with a glow that follows the pointer.
Backgrounds · WebGL2

Rays
FreeShafts of light through haze from a source off the frame, with dust drifting in the beams and optional window blinds.
Backgrounds · WebGL2

Aurora
FreeCurtains of northern light over a night sky and a mountain ridge, drawn toward the pointer.
Backgrounds · WebGL2