Why compositing matters

Traditional effect systems use one attribute per effect: data-shimmer, data-neon, data-reveal. This creates two problems:

  • No stacking — you can't combine shimmer + gradient-text on the same element.
  • Hardcoded triggers — each effect decides when it activates (scroll, hover, always). You can't change the trigger without changing the effect.

The data-effect system solves both. Effects are space-separated values that compose at the CSS selector level. Triggers are a separate attribute.

How effects compose

Most effects use different CSS properties and compose naturally — the browser applies both without conflict:

EffectPrimary CSS Property
shimmerbackground (clip: text)
neontext-shadow, color
outline-webkit-text-stroke
gradient-textbackground (clip: text)
rainbowfilter (hue-rotate)
bounceanimation (transform)
wiggleanimation (rotate)
sparkle::before / ::after

When two effects target different properties, they compose with zero extra CSS.

Natural composition

Neon uses text-shadow. Rainbow uses filter. Both apply simultaneously:

<h1 data-effect="neon rainbow">Neon Rainbow</h1> <h2 data-effect="outline shimmer">Outlined Shimmer</h2> <h2 data-effect="sparkle gradient-text" class="sunset">Sparkle Gradient</h2>

Designed combinations

When two effects target the same CSS property (like text-shadow), VB ships a compound CSS rule that merges them:

[data-effect~="neon"][data-effect~="hard-shadow"] { text-shadow: /* neon glow shadows */ 0 0 0.04em color-mix(in oklch, var(--vb-neon-color), white 60%), 0 0 0.12em var(--vb-neon-color), /* hard shadow */ var(--vb-shadow-offset) var(--vb-shadow-offset) 0 var(--vb-shadow-color); }

The override hierarchy

Tune composed effects with the standard CSS cascade:

  1. Built-in defaultdata-effect="fade-in"
  2. Class variantclass="slow" sets --vb-duration: 900ms
  3. Inline stylestyle="--vb-duration: 2s"
<h2 data-effect="fade-in slide-up" data-trigger="scroll">Default</h2> <h2 data-effect="fade-in slide-up" data-trigger="scroll" class="slow">Slow</h2> <h2 data-effect="fade-in slide-up" data-trigger="scroll" class="bouncy">Bouncy</h2> <h2 data-effect="fade-in slide-up" data-trigger="scroll" style="--vb-duration: 3s">Custom</h2>

Trigger independence

An effect doesn't know what triggered it. The same fade-in slide-up effect can be activated by scroll, hover, click, or a timer — the visual result is identical:

<h2 data-effect="fade-in slide-up" data-trigger="scroll">Scroll</h2> <h2 data-effect="fade-in slide-up" data-trigger="hover">Hover</h2> <button data-effect="fade-in slide-up" data-trigger="click">Click</button> <h2 data-effect="fade-in slide-up" data-trigger="time:2000">2s delay</h2>

Stagger as composition

Parent stagger + child effects = choreographed sequences. The data-stagger attribute sets --vb-stagger-index on each child, cascading the --vb-delay:

<ul data-stagger="80ms"> <li data-effect="fade-in slide-up" data-trigger="scroll">First</li> <li data-effect="fade-in slide-up" data-trigger="scroll">Second</li> <li data-effect="fade-in slide-up" data-trigger="scroll">Third</li> </ul>

Extending with VB.effect()

Register custom effects using the VB API. Custom effects compose with built-in effects automatically:

VB.effect('count-up', (el) => { const target = Number(el.getAttribute('value') || '0'); return { activate() { // Animate from 0 to target const duration = 1500; let start = null; function tick(ts) { if (!start) start = ts; const progress = Math.min((ts - start) / duration, 1); el.textContent = Math.round(target * progress).toLocaleString(); if (progress < 1) requestAnimationFrame(tick); } requestAnimationFrame(tick); } }; }); <data value="48200" data-effect="count-up" data-trigger="scroll">0</data>

Extending with VB.trigger()

Register custom triggers that work with any effect:

VB.trigger('visible', (el, activate, param) => { const threshold = parseInt(param || '50', 10) / 100; const io = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { io.disconnect(); activate(); } }, { threshold }); io.observe(el); return () => io.disconnect(); }); <h2 data-effect="fade-in" data-trigger="visible:50">50% visibility threshold</h2>