Thread Lines

A wide ribbon of fine threads behind a section's content, like silk: dozens of hair-thin strands flowing across the region at a slight angle, close together near one end, almost a single cord, and parting as they go toward the other, each taking a path of its own. The bundle rides slow waves that travel along it, so it shimmers, and the pointer parts the threads around itself, like fingers drawn through silk.

Installation

Import the class, and @use the entry point in your global styles. Nothing warns you when the @use is missing.

import { Component } from '@angular/core';
import { WrThreadLines } from 'ngwr/animations/thread-lines';

@Component({ imports: [WrThreadLines] })
export class MyComponent {}

Runs on the GPU

It draws on the GPU through WebGL, at a capped resolution, only while on screen and the tab is visible, and at 30 frames a second until the pointer parts the threads. Without WebGL it draws nothing. Use one WebGL effect per view, and keep it off low-end and battery-sensitive pages if in doubt.

Basic

Put <wr-thread-lines /> first inside the region it should fill. The drawing is one WebGL pass: every pixel measures its distance to every strand, so a strand is a crisp anti-aliased hairline with a faint glow, never a stroke. The context is made when the host is first on screen and sized, and the loop runs while it stays on screen and the document is visible: thirty frames a second while only the waves move, the screen's rate while the parting is live. The parent takes the pointer, passively; a finger is ignored, so a scroll over the section is never fought. Under RTL the ribbon is mirrored and gathers at the inline start.

<section class="opt-in">
  <wr-thread-lines />
  <h2>Opt in to what you need.</h2>
</section>

Draw the pointer through the threads.

They part around it and close again behind it.

The parent

The host is absolute, inset: 0, at z-index: -1, under no pointer. The parent provides two things: it is positioned, so the host fills it, and isolated, so the host's negative z-index lands at the floor of the parent's own stacking context, under the content and over the page background.

.opt-in {
  position: relative;
  isolation: isolate;
}

Calmer

count is how many strands the bundle has, amplitude how high the wave lifts it, in px, speed how fast the wave travels and fray how far the strands part by the far end; none of it keeps them a cord the whole way. Fewer strands, a lower and slower wave and a narrower fray is a ribbon that barely stirs.

<wr-thread-lines count="48" amplitude="8" speed="0.6" fray="0.6" />

Default

As it comes.

count="48" amplitude="8" speed="0.6" fray="0.6"

Calm.

Dense and frayed

More strands, a wider fray, a steeper angle (degrees; negative rises toward the far end) and a finer thickness (device px) is a frayed sheet of silk rather than a cord. The count is capped at 128: the shader walks every strand for every pixel, and the canvas renders under a pixel budget of its own, so a wide section is never a wide texture.

<wr-thread-lines count="110" fray="1.6" angle="-10" thickness="0.8" />

A frayed sheet.

Colours

The middle of the bundle is --wr-thread-lines-color, the body's ink by default, and the outer strands blend toward --wr-thread-lines-accent, the primary by default; set either on the host, where the defaults are declared. Both are read through resolveCssColorChannels and re-read when the scheme changes. The surface is read too: on a light one the strands are laid as ink, darker than the surface, with next to no glow; on a dark one they are lit, with a soft glow round each.

<wr-thread-lines style="--wr-thread-lines-color: var(--wr-color-info); --wr-thread-lines-accent: var(--wr-color-warning)" />

Info, fraying into warning.

Parting

parting is how strongly the pointer parts the strands: the gap is a lens drawn out along the ribbon, whole at the pointer and gone about 110px across from it, and it opens and closes on a slightly under-damped spring, so the threads swing a little past and back when the pointer leaves. None turns it off, and the loop then holds at thirty frames a second throughout.

<wr-thread-lines parting="0" />

Default

The threads part.

parting="0"

The pointer does nothing here.

API

NameDescriptionTypeDefault
countHow many strands the bundle has, 128 at most.number72
thicknessHow thick a strand is, in device px.number1
amplitudeHow high the travelling wave lifts the bundle, in CSS px.number16
speedHow fast the wave travels, a multiplier: one is a wave every nine seconds.number1
frayHow far the strands part by the far end, a multiplier: none keeps them a cord the whole way.number1
angleThe ribbon's tilt, in degrees: positive falls toward the far end, negative rises.number-6
partingHow strongly the pointer parts the strands, a multiplier: none turns the parting off.number1

Accessibility

PartBehaviour
Screen readers Decorative: the host is aria-hidden, holds no text, takes no pointer and is never focusable. The listeners on the parent are passive and stop nothing, so every control over the ribbon behaves as before.
Touch A finger is ignored: it parts nothing, nothing is prevented and touch-action is left alone, so a scroll that starts over the region scrolls.
Reduced motion Under prefers-reduced-motion: reduce one still frame of the closed ribbon is drawn and nothing moves; a preference that flips mid-flight stops the loop at once. Under forced-colors: active nothing is drawn.
Contrast A strand is a hairline laid in the body's ink at well under full coverage, brightest in the middle of the bundle and faded at its edges and ends, so the text over it reads as it does on the plain surface.
No WebGLWithout WebGL nothing is drawn; a lost context is let go and rebuilt when the browser restores it.
ServerThe empty canvas is rendered, absolutely positioned, so the browser's first draw shifts no layout.

CSS variables

Set these on the element itself, with a rule more specific than its Declared on selector. In a global stylesheet, put a class of yours on an ancestor it really has: <body> for an overlay panel, which renders outside your markup. In a component stylesheet the selector alone works, but only for an element in that component's own template. A value on a wrapper or :root is shadowed, because ngwr/animations/thread-lines declares each default on its own element.

VariableDefaultDeclared on
--wr-thread-lines-accentvar(--wr-color-primary).wr-thread-lines
--wr-thread-lines-colorvar(--wr-color-on-surface).wr-thread-lines

See also