<div class="lsd-mass-anchor">Heaviest</div>
<div class="lsd-mass-medium">Mid</div>
<div class="lsd-mass-feather">Wisp</div>
Motion library
Every class with a live demo, the HTML, the SCSS mixin, and the raw CSS. Click any card's Copy to grab the active tab.
Entrance (transitions)
Each element starts in a hidden initial state and transitions to rest when .lsd-fx--in is added (the IntersectionObserver in lsd-anim.js does this automatically on viewport enter).
Keyframed effects
Multi-stage @keyframes you can't express as a two-state transition. Attention-getters + continuous loops.
Hover interactions
Pure CSS. Drop on any interactive element. All values token-driven so they adapt to palette swaps.
Cursor mask reveal
Cursor becomes a porthole through a top layer. lsd-pointer.js auto-wires — add data-lsd-pointer-lerp="0.2" for smoothed follow.
Scroll-driven
Native animation-timeline: view(). Feature-gated: browsers without support play the keyframes once as the section scrolls into view.
Mass / gravity
Elements declare weight; siblings yield to the heaviest one. Tier 1 static classes, Tier 2 CSS :has() fields, Tier 3 JS (lsd-mass.js) computes vectors. Progressive — each tier degrades cleanly.
<div class="lsd-mass-field">
<div class="lsd-mass-feather lsd-mass-yield-left"></div>
<div class="lsd-mass-anchor"></div>
<div class="lsd-mass-light lsd-mass-yield-right"></div>
</div>
<div data-lsd-mass="field">
<div class="lsd-mass-feather"></div>
<div class="lsd-mass-anchor"></div>
<div class="lsd-mass-medium"></div>
</div>
<script type="module" src="/lsd-mass.js"></script>
Dynamic gravity
Mass isn't a static label — it's a runtime axis. Swap a child's mass class on any event (hover, scroll, enter, exit) and lsd-mass.js re-emits yield vectors across every sibling. Compose with lsd-physics.js (spring + magnet) to make gravity a real, composable behavior — not a single hard-wired animation.
<div data-lsd-mass="field">
<div class="lsd-mass-light"></div> … 5x
</div>
<script>
field.addEventListener('mouseover', e => {
const cell = e.target.closest('.dyn-cell'); if (!cell) return
field.querySelectorAll('.lsd-mass-anchor')
.forEach(n => n.classList.replace('lsd-mass-anchor','lsd-mass-light'))
cell.classList.replace('lsd-mass-light','lsd-mass-anchor')
})
</script>
// progress = how far the card has travelled through the viewport
const i = Math.min(cells.length - 1, Math.floor(progress * cells.length))
if (i !== current) {
cells[current].classList.replace('lsd-mass-anchor','lsd-mass-light')
cells[i].classList.replace('lsd-mass-light','lsd-mass-anchor')
current = i
}
<div data-lsd-magnet="0.8" data-lsd-spring="0,0"
class="lsd-mass-heavy"></div>
<style>
[data-lsd-magnet].lsd-mass-feather { --lsd-magnet-radius: 220px; }
[data-lsd-magnet].lsd-mass-light { --lsd-magnet-radius: 170px; }
[data-lsd-magnet].lsd-mass-medium { --lsd-magnet-radius: 130px; }
[data-lsd-magnet].lsd-mass-heavy { --lsd-magnet-radius: 90px; }
[data-lsd-magnet].lsd-mass-anchor { --lsd-magnet-radius: 60px; }
</style>
field.addEventListener('click', e => {
const orb = e.target.closest('.dyn-orb'); if (!orb) return
field.querySelector('.lsd-mass-anchor')
?.classList.replace('lsd-mass-anchor','lsd-mass-light')
orb.classList.replace('lsd-mass-light','lsd-mass-anchor')
// lsd-mass.js MutationObserver picks up the swap and
// re-emits --lsd-yield-x/y on every sibling automatically.
})
// for each middle particle:
const dxA = ax - mx, dyA = ay - my
const dxB = bx - mx, dyB = by - my
const yx = (dxA / |A|) * massA + (dxB / |B|) * massB
const yy = (dyA / |A|) * massA + (dyB / |B|) * massB
particle.style.setProperty('--lsd-yield-x', yx + 'px')
particle.style.setProperty('--lsd-yield-y', yy + 'px')
click => promote(target, 'anchor')
// then for distance d = 1, 2, 3…
setTimeout(() => {
promote(cells[i - d], TIERS[d]) // medium → light → feather
promote(cells[i + d], TIERS[d])
}, d * 120)
function mulberry32(a) {
return () => {
a |= 0; a = a + 0x6D2B79F5 | 0
let t = Math.imul(a ^ a >>> 15, 1 | a)
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t
return ((t ^ t >>> 14) >>> 0) / 4294967296
}
}
const rng = mulberry32(seed)
cells.forEach(c => setMass(c, TIERS[Math.floor(rng() * 5)]))
agent.addEventListener('pointerup', () => {
const ar = agent.getBoundingClientRect()
for (const field of fields) {
const fr = field.getBoundingClientRect()
if (inside(ar, fr)) {
field.querySelector('.lsd-mass-anchor')
?.classList.replace('lsd-mass-anchor','lsd-mass-medium')
agent.removeAttribute('data-lsd-throw') // reset transform
field.appendChild(agent)
agent.classList.add('lsd-mass-anchor')
agent.setAttribute('data-lsd-throw','') // re-arm
}
}
})
setInterval(() => {
cells.forEach(c => setTier(c, TIERS[Math.floor(Math.random() * 5)]))
}, 4000)
// mass.js → --lsd-yield-x/y → spring.js absorbs the step
// → cells drift to new rest positions instead of snapping
// each frame:
const cx = (aRect.cx + bRect.cx) / 2
const cy = (aRect.cy + bRect.cy) / 2
for (const p of particles) {
const dx = cx - p.cx, dy = cy - p.cy
const len = Math.hypot(dx, dy) || 1
// OVERWRITE what lsd-mass.js wrote — same custom props.
p.style.setProperty('--lsd-yield-x', (dx / len * DIST) + 'px')
p.style.setProperty('--lsd-yield-y', (dy / len * DIST) + 'px')
}
area.addEventListener('pointermove', () => {
const p = document.createElement('div')
p.className = 'd13-trail lsd-mass-feather'
p.style.setProperty('--mass', '0.15')
p.style.left = comet.style.left
p.style.top = comet.style.top
area.appendChild(p)
setTimeout(() => p.remove(), 800)
// cap to ~30 active so the field stays solvable
})
// 3-arm Archimedean spiral
const arm = i % 3
const t = Math.floor(i / 3)
const r = 14 + t * 11
const ang = arm * (Math.PI * 2 / 3) + t * 0.55
// orbital speed inverse to radius
item.style.setProperty('--orbital-speed', (r / 14).toFixed(2))
// radius compresses as center-mass rises
const pull = 1 - 0.55 * (mass - 0.3) / 0.7
item.style.transform = `translate(${r*pull*Math.cos(ang)}px, ${r*pull*Math.sin(ang)}px)`
// per frame, nested polar
const pa = (t / period) * Math.PI * 2
planet.style.transform = `translate(${r*Math.cos(pa)}px, ${r*Math.sin(pa)}px)`
const ma = (t / moon.period) * Math.PI * 2
moon.style.transform = `translate(${mr*Math.cos(ma)}px, ${mr*Math.sin(ma)}px)`
@keyframes d19-pulse {
0%,100% { --mass: 1.0; box-shadow: 0 0 30px var(--color-accent); }
50% { --mass: 0.6; box-shadow: 0 0 12px var(--color-accent); }
}
.d19-core { animation: d19-pulse 1.2s ease-in-out infinite; }
const a = parseFloat(wrap.style.getPropertyValue('--wind-angle'))
const vx = Math.cos(a * Math.PI / 180)
const vy = Math.sin(a * Math.PI / 180)
for (const p of particles) {
const k = STRENGTH * (1 + 1 / Math.max(20, p.dist))
p.style.setProperty('--lsd-yield-x', (vx * k) + 'px')
p.style.setProperty('--lsd-yield-y', (vy * k) + 'px')
}
const d = Math.hypot(dx, dy)
if (d < LENS_R) {
const k = Math.pow(1 - d / LENS_R, 2)
item.style.transform = `translate(${-dx*k*0.35}px, ${-dy*k*0.35}px) scale(${1 + k*0.6})`
}
sat.r -= (0.1 / sat.r) * dt
if (sat.r < 20) {
sat.el.animate([{ opacity:1, scale:1 }, { opacity:0, scale:0 }], 400)
coreMass += 0.05
core.style.setProperty('--mass', coreMass)
}
// emergent center: weighted average of all particle positions
const com = particles.reduce((a, p) => ({
x: a.x + p.x * p.mass, y: a.y + p.y * p.mass, m: a.m + p.mass,
}), { x:0, y:0, m:0 })
const cx = com.x / com.m, cy = com.y / com.m
// when collapsed → ignite
if (avgDistFromCoM < 30) spawnStar(cx, cy)
// orbit position
const a = (t / period) * Math.PI * 2
sat.style.transform = `translate(${r*Math.cos(a)}px, ${r*Math.sin(a)}px)`
// marker faces the anchor every frame
const facing = Math.atan2(coreY - satY, coreX - satX)
marker.style.transform = `translate(0,-50%) rotate(${facing}rad)`
// each cell: a unique curve over scroll progress p ∈ [0,1]
cell1.style.setProperty('--mass', 1 - 0.8 * p)
cell2.style.setProperty('--mass', 0.2 + 0.8 * Math.abs(0.5 - p) * 2)
cell3.style.setProperty('--mass', 0.5) // constant
cell4.style.setProperty('--mass', 0.2 + 0.8 * (1 - Math.abs(0.5 - p) * 2))
cell5.style.setProperty('--mass', 0.2 + 0.8 * p)
Authoring tokens
Duration, delay, ease, distance — all CSS vars. Override per element or at :root for a brand-wide shift.
:root {
--lsd-motion-duration-xs: 120ms;
--lsd-motion-duration-sm: 200ms;
--lsd-motion-duration-md: 400ms;
--lsd-motion-duration-lg: 700ms;
--lsd-motion-duration-xl: 1100ms;
--lsd-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--lsd-ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--lsd-ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--lsd-ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
--lsd-motion-distance-md: 32px;
--lsd-mask-radius: 88px;
--lsd-mask-softness: 28px;
}