LSD Framework

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.

Tier 1 .lsd-mass-{feather…anchor}

Five declarative bins. Each sets --mass, a scale delta, and a shadow. No JS.

<div class="lsd-mass-anchor">Heaviest</div>
<div class="lsd-mass-medium">Mid</div>
<div class="lsd-mass-feather">Wisp</div>

Tier 2 .lsd-mass-field + :has()

Parent with .lsd-mass-field. Siblings add .lsd-mass-yield-left/right/up/down and the CSS :has() rule does the rest. No JS required.

<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>

Tier 3 [data-lsd-mass="field"]

Astronaut measures rects, finds the heaviest child, and writes --lsd-yield-x/y per sibling. Auto-wires on load; re-distributes on DOM or resize changes. Degrades to Tier 2 / Tier 1 without JS.

<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.

Mass on hover anchor follows the cursor

Hovering any cell promotes it to .lsd-mass-anchor; the previous anchor demotes back to .lsd-mass-light. The field re-solves: yield vectors flip in-flight and every other sibling pushes away from whichever cell you're pointing at.

<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>

Mass on scroll gravity follows the playhead

The card watches its own viewport progress (0 at the top of the viewport, 1 at the bottom). The anchor rotates through siblings as you scroll. The same JS pipeline that re-flows on hover re-flows on scroll — proof that mass is just data, the event source is interchangeable.

// 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
}

Magnet vs spring mass × pull radius

Each circle is a data-lsd-magnet + data-lsd-spring="0,0" pair — pulls toward the cursor, springs back on exit. A single CSS rule scales --lsd-magnet-radius by mass: feather pulls from 220px away, anchor barely twitches at 60px. Two independent runtimes (mass + physics) composing through CSS variables.

<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>

Gravity well click to reassign the anchor

Eight satellites orbit one anchor in a 3×3 field. Click any satellite to become the new gravitational center — the previous anchor demotes, every sibling re-solves its yield vector against the new mass. Proves anchor identity is fluid: gravity reassigns on a click, not just on a hover scrub.

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.
})

Tug-of-war two anchors, summed pulls

Two anchors fight over five middle particles. Each particle's yield vector is computed manually in JS — pull(A) + pull(B) weighted by each anchor's current mass. Hover either anchor to make it the heavier one; the middle row drifts toward whoever's winning. Shows that when one anchor isn't enough, you drop into the solver directly and just write --lsd-yield-x/y.

// 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')

Cascade staggered chain reaction

Click any cell to promote it to anchor. A 120ms-staggered wave then ripples outward — direct neighbours demote, then their neighbours, then theirs — producing a visible mass gradient: anchor → medium → light → feather. Each step is a class swap, and the field re-solves on every tick. Mass changes propagate as events, not as a single batch.

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)

Random shuffle deterministic seed

Press Shuffle to re-roll every cell's mass class from the catalog. The field redistributes — heaviest cell becomes anchor, lighter ones yield away. The same seed always produces the same layout (mulberry32 PRNG), so the field's equilibrium is reproducible. Proof that no matter the starting state, the solver reaches stability immediately.

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)]))
free agent →

Drag-to-promote mass crosses fields

Two sister fields, each with its own anchor. Drag the free agent into either one — on drop the cell is re-parented, promoted to .lsd-mass-anchor, and the previous anchor demotes to .lsd-mass-medium. Both fields re-solve independently. Proves anchorhood is field-local: mass is whatever you assign in the parent you currently belong to.

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
    }
  }
})

Constellation respawn mass writes, spring smooths

Every 4 seconds, each cell's mass class is randomized. lsd-mass.js rewrites --lsd-yield-x/y on every sibling; data-lsd-spring="0,0" intercepts the target and oscillates toward the new equilibrium instead of snapping. Two runtimes layered: mass declares where, spring decides how it arrives.

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

Binary barycenter derived mass center

Two anchors A and B orbit a shared invisible point on a CSS keyframe. The runtime would pick one as "the heaviest" — instead, JS overrides by computing the live midpoint each frame and writing --lsd-yield-x/y on each middle particle pointing toward that midpoint. Particles drift around the moving barycenter, not either star alone.

// 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')
}

Comet trail ephemeral mass

The comet tracks the cursor with a small magnet radius. Every pointermove spawns a trail particle at the comet's location with --mass: 0.15; it animates opacity 1→0 + scale 1→0.3 over 800ms, then removes itself. While alive it nudges nearby resting items. Mass can be born and die — items in the field don't need to persist to exert influence.

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
})

Galaxy spiral slider drives center mass

30 specks arranged on a 3-arm Archimedean spiral. Each rotates on its own CSS keyframe — angular speed inverse to radius (inner = fast, outer = slow). The slider sets --center-mass on the core (0.3 → 1.0). Higher mass = items pulled into tighter orbits in real time. The field reshapes on every continuous input, not just on class swaps.

0.60
// 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)`

Planetary system three-tier mass hierarchy

A single anchor star, three heavy planets on independent periods orbiting it, and 1–2 light moons orbiting each planet. A single rAF loop computes nested polar coordinates — moon position = planet position + moon offset — so the mass hierarchy reads spatially. Anchor > heavy > light, recursively.

// 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)`

Pulsar --mass on a heartbeat

The center's --mass oscillates between 0.6 and 1.0 on a CSS keyframe; a synced box-shadow animation supplies the visual heartbeat. Eight satellites ring it — their yield vectors pulse in/out each beat because lsd-mass.js's push distance scales with mass delta. Same demo, but mass is now time.

@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; }

Solar wind directional yield-vec overlay

The slider writes --wind-angle on the wrapper. rAF reads it, converts to a unit vector, and writes --lsd-yield-x/y on each particle as windVec × strength × (1 + 1/distance). Particles drift downwind, closer-in ones stronger. The yield-var contract is generic — anything can be its source.

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')
}

Gravitational lensing distance-weighted warp

Ten light items are scattered across the area; a heavy lens sits at the center. rAF measures each item's distance to the lens — within a 200px radius it scales up and offsets toward the center proportionally to (1 - d/200)². No orbital motion, just a static field shaping space. Mass as a visual operator.

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})`
}

Orbital decay mass accumulates by consumption

Eight satellites in a ring from r=80 to r=200. rAF spirals each radius inward at 0.1/r — closer = faster (Kepler-flavoured). When r < 20 the satellite is consumed: scale+opacity to 0, and the core's --mass grows by 0.05. The anchor visibly accumulates the field it absorbs.

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)
}

Star formation center-of-mass collapse

30 dust particles, no central anchor at start. Each frame the center of mass is computed and every particle accelerates toward it. When the average radius from CoM drops under 30px, an anchor is born at the centroid, the dust is absorbed, and a bloom flashes. Mass emerges from interaction; the field designs itself.

// 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)

Tidal locking marker always faces anchor

Three satellites orbit a central anchor on independent periods. Each satellite has an inner marker. rAF measures the angle from each satellite to the anchor and rotates the marker to match — so the marker always faces inward, like the Moon to Earth. A small, legible reading of mass-as-orientation.

// 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)`

Mass curves continuous --mass from scroll

No classes here — each cell writes --mass directly from a function of scroll progress. Five different curves cross each other as you scroll, so the anchor identity transitions continuously instead of snapping between siblings. Confirms lsd-mass.js's real input is just the --mass number; any source can drive it.

// 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;
}