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:
| Effect | Primary CSS Property |
shimmer | background (clip: text) |
neon | text-shadow, color |
outline | -webkit-text-stroke |
gradient-text | background (clip: text) |
rainbow | filter (hue-rotate) |
bounce | animation (transform) |
wiggle | animation (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:
- Built-in default —
data-effect="fade-in"
- Class variant —
class="slow" sets --vb-duration: 900ms
- Inline style —
style="--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>