Little Loops · Microinteraction Library
8 categories, 30+ live patterns. Every animation you reach for, documented and interactive.
Elements that respond to viewport entry — the foundation of scroll-triggered interfaces. Eight patterns from the simplest opacity fade to CSS-native scroll-driven animations.
Gentle entry.
Arrives cleanly.
Zero friction.
Works everywhere.
opacity 0→1 as element enters viewport
IntersectionObserver + opacityElements fade in as they enter the viewport — the simplest, most universal scroll entrance.
.fade-card {
opacity: 0;
transform: translateY(12px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.fade-card.visible {
opacity: 1;
transform: none;
}
const io = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) e.target.classList.add('visible');
});
}, { threshold: 0.15 });
document.querySelectorAll('.fade-card').forEach(el => io.observe(el));
translateX triggers narrative direction
translateX(-60px) → 0Elements slide in from left or right on scroll entry — direction implies narrative flow.
.slide-item {
opacity: 0;
transition: opacity 0.7s ease, transform 0.7s ease;
}
.slide-item.from-left { transform: translateX(-50px); }
.slide-item.from-right { transform: translateX(50px); }
.slide-item.visible {
opacity: 1;
transform: none;
}
const io = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) e.target.classList.add('visible');
});
}, { threshold: 0.15 });
document.querySelectorAll('.slide-item').forEach(el => io.observe(el));
Sequential delays create reading rhythm
transition-delay × indexList items animate in one after another — sequential delays build a reading rhythm.
.stag-item {
opacity: 0;
transform: translateY(16px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.stag-item.visible {
opacity: 1;
transform: none;
}
new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
e.target.querySelectorAll('.stag-item').forEach((item, i) => {
setTimeout(() => item.classList.add('visible'), i * 90);
});
});
}, { threshold: 0.1 }).observe(listParent);
Text rises from clipping mask — editorial feel
translateY(110%) → 0Text lines rise from a clipping mask on scroll entry — gives an editorial, editorial-magazine feel.
<div class="clip-reveal"> <div class="clip-wrap"><span class="clip-line">First line</span></div> <div class="clip-wrap"><span class="clip-line">Second line</span></div> </div>
.clip-wrap { overflow: hidden; height: 1.25em; }
.clip-line {
display: block;
transform: translateY(110%);
transition: transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}
.clip-reveal.visible .clip-line { transform: none; }
.clip-reveal.visible .clip-line:nth-child(2) { transition-delay: 0.1s; }
.clip-reveal.visible .clip-line:nth-child(3) { transition-delay: 0.2s; }
new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) e.target.classList.add('visible');
});
}, { threshold: 0.15 }).observe(document.querySelector('.clip-reveal'));
Spring easing from 75% — satisfying bounce
scale(0.75) → scale(1) springGrid items spring up from 75% scale on scroll entry — the spring easing makes it feel physical.
.scale-card {
opacity: 0;
transform: scale(0.75);
transition:
opacity 0.6s ease,
transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.scale-card.visible {
opacity: 1;
transform: scale(1);
}
new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
e.target.querySelectorAll('.scale-card').forEach((c, i) => {
setTimeout(() => c.classList.add('visible'), i * 75);
});
});
}, { threshold: 0.1 }).observe(gridParent);
Numbers climb on entry — instant eye grab
rAF interpolation on entryNumbers count up from 0 when they enter the viewport — a reliable attention hook for stats.
<div class="counter-card" data-target="98" data-suffix="%"> <span class="counter-num">0<span class="suffix">%</span></span> <div class="counter-lbl">Score</div> </div>
new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
const target = +e.target.dataset.target;
const numEl = e.target.querySelector('.counter-num');
let start = null;
(function step(ts) {
if (!start) start = ts;
const p = Math.min((ts - start) / 1400, 1);
const ease = 1 - Math.pow(1 - p, 3);
numEl.childNodes[0].textContent = Math.round(ease * target);
if (p < 1) requestAnimationFrame(step);
})(performance.now());
io.unobserve(e.target);
});
}, { threshold: 0.15 }).observe(counterEl);
Layer speed differentials suggest physical depth
scrollY × multiplier per layerMultiple layers move at different speeds as you scroll — speed differential implies physical depth.
.para-box { overflow: hidden; position: relative; }
.para-bg { position: absolute; inset: -60px; will-change: transform; }
.para-mid { position: absolute; will-change: transform; }
.para-fg { position: relative; z-index: 2; }
window.addEventListener('scroll', () => {
const rect = box.getBoundingClientRect();
const center = rect.top + rect.height / 2 - window.innerHeight / 2;
bg.style.transform = `translateY(${center * 0.3}px)`;
mid.style.transform = `translateY(${center * 0.6}px)`;
}, { passive: true });
Zero JS — animation-timeline: view()
animation-timeline: view()Pure CSS scroll animation — no JavaScript. Runs on the compositor thread for zero jank.
@keyframes rise {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: none; }
}
.scroll-driven {
animation: rise linear both;
animation-timeline: view();
animation-range: entry 5% entry 35%;
}
/* Progress bar tied to scroll position */
.progress-bar {
animation: grow-bar linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
transform-origin: left;
}
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Active states, progress indicators, and nav behaviors that orient users without distraction. Three patterns covering tabs, step flows, and scrollspy.
Sliding underline tracks active tab smoothly
left + width transition on indicatorA smooth underline that slides and scales to match the active tab — feels responsive without being jarring.
.tab-nav { display: flex; border-bottom: 1px solid var(--border); position: relative; }
.tab-btn { background: none; border: none; color: var(--muted); padding: 9px 14px; cursor: pointer; z-index: 1; }
.tab-btn.active { color: var(--c2); }
.tab-indicator {
position: absolute; bottom: -1px; height: 2px; background: var(--c2);
transition: left 0.3s cubic-bezier(0.4,0,0.2,1), width 0.3s cubic-bezier(0.4,0,0.2,1);
}
.tab-body { padding: 12px 2px; font-size: 12px; color: var(--muted); min-height: 40px; }
const tabBtns = document.querySelectorAll('.tab-btn');
const tabInd = document.getElementById('tab-ind');
function setTab(btn) {
tabBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
tabInd.style.left = btn.offsetLeft + 'px';
tabInd.style.width = btn.offsetWidth + 'px';
}
tabBtns.forEach(btn => btn.addEventListener('click', () => setTab(btn)));
requestAnimationFrame(() => setTab(tabBtns[0]));
Animated breadcrumb for multi-step flows
class toggle + line fillVisual breadcrumb for multi-step flows — dots animate as you progress, lines fill behind you.
.step-track { display: flex; align-items: center; gap: 0; margin-bottom: 14px; }
.step-dot { width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 10px; color: var(--muted); flex-shrink: 0; transition: all 0.3s ease; cursor: pointer; background: var(--surface); }
.step-dot.active { border-color: var(--c2); background: var(--c2); color: var(--bg); }
.step-dot.done { border-color: var(--c2); color: var(--c2); }
.step-line { flex: 1; height: 2px; background: var(--border); transition: background 0.4s ease; }
.step-line.done { background: var(--c2); }
const stepDots = document.querySelectorAll('.step-dot');
const stepLines = [document.getElementById('sl-0'), document.getElementById('sl-1')];
function updateSteps(currentStep) {
stepDots.forEach((d, i) => {
d.classList.toggle('active', i === currentStep);
d.classList.toggle('done', i < currentStep);
});
stepLines.forEach((l, i) => l.classList.toggle('done', i < currentStep));
}
document.getElementById('step-btn').addEventListener('click', () => {
currentStep = (currentStep + 1) % 4;
updateSteps();
});
The starting point. Context before content.
Specifics that build on the foundation.
Concrete patterns in the real world.
What to take away and apply tomorrow.
Active link updates as you scroll through sections
IntersectionObserver on sectionsNavigation that reflects your position in scrollable content — the active link updates as sections come into view.
.spy-sidebar { display: flex; flex-direction: column; gap: 3px; flex-shrink: 0; padding-top: 2px; }
.spy-link { font-size: 10px; color: var(--muted); padding: 4px 8px; border-left: 2px solid var(--border); cursor: pointer; transition: all 0.2s; white-space: nowrap; }
.spy-link.active { color: var(--c2); border-left-color: var(--c2); }
.spy-scroll { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
.spy-sec { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 14px; flex-shrink: 0; }
const spyScroll = document.getElementById('spy-scroll');
const spySections = spyScroll.querySelectorAll('.spy-sec');
const spyLinks = document.querySelectorAll('.spy-link');
const spyIO = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
spyLinks.forEach(l => l.classList.toggle('active', l.dataset.target === e.target.id));
}
});
}, { root: spyScroll, threshold: 0.6 });
spySections.forEach(s => spyIO.observe(s));
spyLinks.forEach(link => {
link.addEventListener('click', () => {
document.getElementById(link.dataset.target).scrollIntoView({ behavior: 'smooth' });
});
});
Interactions that respond to pointer presence — personality, invitation, and depth. Custom cursors, magnetic pull, 3D tilt, and text colour wipes.
Dot + lagging ring — contained to demo area
mousemove → position dot + ringA custom cursor with a dot and lagging ring that tracks mouse movement — feels tactile and engaged.
.cursor-zone { width: 100%; height: 200px; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; position: relative; cursor: none; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.cur-dot { position: absolute; width: 6px; height: 6px; border-radius: 50%; background: var(--c3); transform: translate(-50%,-50%); pointer-events: none; z-index: 10; transition: opacity 0.2s; }
.cur-ring { position: absolute; width: 30px; height: 30px; border-radius: 50%; border: 1.5px solid var(--c3); transform: translate(-50%,-50%); pointer-events: none; z-index: 9; transition: transform 0.08s ease, width 0.15s ease, height 0.15s ease; }
const zone = document.getElementById('cursor-zone');
const dot = document.getElementById('cur-dot');
const ring = document.getElementById('cur-ring');
let ringX = 0, ringY = 0;
zone.addEventListener('mouseenter', () => { dot.style.display = ''; ring.style.display = ''; });
zone.addEventListener('mouseleave', () => { dot.style.display = 'none'; ring.style.display = 'none'; });
zone.addEventListener('mousemove', e => {
const r = zone.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
dot.style.left = x + 'px';
dot.style.top = y + 'px';
ringX += (x - ringX) * 0.12;
ringY += (y - ringY) * 0.12;
ring.style.left = ringX + 'px';
ring.style.top = ringY + 'px';
});
Buttons drift toward cursor when nearby
mousemove → translate offsetButtons that gravitate toward your cursor when you hover near them — playful and inviting.
.mag-btn { background: var(--surface2); border: 1px solid var(--border); color: var(--text); font-family: 'DM Mono', monospace; font-size: 12px; padding: 12px 22px; border-radius: 8px; cursor: pointer; will-change: transform; transition: border-color 0.2s, color 0.2s, box-shadow 0.3s; }
.mag-btn:hover { border-color: var(--c3); color: var(--c3); box-shadow: 0 0 28px rgba(255,107,107,.18); }
document.querySelectorAll('[data-mag]').forEach(btn => {
btn.addEventListener('mousemove', e => {
const r = btn.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
btn.style.transform = `translate(${dx * 0.28}px, ${dy * 0.28}px)`;
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = '';
btn.style.transition = 'transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), border-color 0.2s, color 0.2s, box-shadow 0.3s';
setTimeout(() => { btn.style.transition = ''; }, 400);
});
});
3D depth on hover
Perspective transform
Perspective rotateX/Y from cursor position
rotateX + rotateY on mousemoveCards that rotate in 3D space based on cursor position — creates a sense of depth and interactivity.
.tilt-row { display: flex; gap: 14px; width: 100%; perspective: 700px; }
.tilt-card { flex: 1; background: linear-gradient(135deg, var(--surface2), var(--surface)); border: 1px solid var(--border); border-radius: 10px; padding: 18px; transform-style: preserve-3d; transition: box-shadow 0.3s; cursor: default; }
.tilt-card:hover { box-shadow: 0 16px 48px rgba(0,0,0,.4); }
document.querySelectorAll('[data-tilt]').forEach(card => {
card.addEventListener('mousemove', e => {
const r = card.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width - 0.5;
const y = (e.clientY - r.top) / r.height - 0.5;
card.style.transform = `rotateY(${x * 16}deg) rotateX(${-y * 12}deg)`;
});
card.addEventListener('mouseleave', () => {
card.style.transform = '';
card.style.transition = 'transform 0.5s ease, box-shadow 0.3s';
setTimeout(() => { card.style.transition = ''; }, 500);
});
});
Colour reveals across text on hover via clip-path
clip-path: inset(0 100% 0 0) → 0Text that changes colour with a smooth wipe from left to right on hover — elegant and understated.
.hover-links { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.hover-link { display: inline-block; font-family: 'DM Serif Display', serif; font-size: clamp(18px, 2.5vw, 26px); color: var(--text); text-decoration: none; position: relative; overflow: hidden; line-height: 1.2; }
.hover-link::after { content: attr(data-text); position: absolute; inset: 0; color: var(--c3); clip-path: inset(0 100% 0 0); transition: clip-path 0.5s cubic-bezier(0.16, 1, 0.3, 1); }
.hover-link:hover::after { clip-path: inset(0 0% 0 0); }
<a class="hover-link" href="#" data-text="New colour">Original text</a>
Perceived performance is performance. These patterns keep users oriented while content arrives — skeletons, spinners, and staggered entrances.
Shimmer placeholders — shape before content
shimmer gradient animationPlaceholder blocks that shimmer while content loads — telegraphs shape and structure before text arrives.
.skel-card { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 16px; width: 100%; }
.skel-line { height: 10px; border-radius: 4px; background: var(--border); position: relative; overflow: hidden; }
.skel-line::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, var(--shimmer-highlight, rgba(255,255,255,.06)), transparent); animation: shimmer 1.6s infinite; }
@keyframes shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
.skel-avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--border); position: relative; overflow: hidden; }
.skel-avatar::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, var(--shimmer-highlight, rgba(255,255,255,.06)), transparent); animation: shimmer 1.6s infinite; }
Four loading states — ring, dual, pulse, dash
border + SVG stroke-dasharrayFour spinner styles: ring (simple), dual (two colors), pulse (dots), and SVG dash (smooth stroke animation).
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes dot-pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.3; } 40% { transform: scale(1); opacity: 1; } }
.sp1 { border-radius: 50%; border: 2px solid var(--border); border-top-color: var(--c4); animation: spin 0.8s linear infinite; }
.sp2 { border-radius: 50%; border: 2px solid transparent; border-top-color: var(--c4); border-bottom-color: var(--c4); animation: spin 1s linear infinite; }
.sp3 { display: flex; align-items: center; justify-content: center; gap: 4px; }
.sp3 span { width: 7px; height: 7px; border-radius: 50%; background: var(--c4); animation: dot-pulse 1.2s ease-in-out infinite; }
.sp3 span:nth-child(2) { animation-delay: 0.15s; }
.sp3 span:nth-child(3) { animation-delay: 0.3s; }
.sp4 { animation: spin 2s linear infinite; }
.sp4 circle { stroke: var(--c4); stroke-dasharray: 80 100; stroke-linecap: round; fill: none; stroke-width: 4; }
Choreographed load sequence — feels crafted
setTimeout stagger on mountCards that fade and scale in one after another on load — choreography signals intentional design.
.entrance-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; width: 100%; }
.en-card { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 14px; opacity: 0; transform: translateY(18px); transition: opacity 0.5s ease, transform 0.5s ease; }
.en-card.in { opacity: 1; transform: none; }
.en-icon { font-size: 22px; margin-bottom: 8px; }
function runEntrance() {
const cards = document.querySelectorAll('.en-card');
cards.forEach(c => c.classList.remove('in'));
cards.forEach((c, i) => {
setTimeout(() => c.classList.add('in'), i * 150 + 80);
});
}
runEntrance(); // Call on page load or button click
Every action deserves a response. These patterns close the loop between user intent and system reaction — button states, toasts, copy confirm, and toggles.
idle → loading → success — one button, three moments
class swap + setTimeoutA button that transitions through idle → loading → success states, showing spinner and checkmark as it progresses.
.btn-state { position: relative; min-width: 148px; padding: 11px 22px; border-radius: 8px; border: none; font-family: 'DM Mono', monospace; font-size: 12px; cursor: pointer; transition: all 0.3s ease; }
.btn-state.idle { background: var(--c5); color: var(--bg); }
.btn-state.loading { background: var(--surface2); color: var(--muted); cursor: wait; }
.btn-state.success { background: var(--c5); color: var(--bg); }
.btn-spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid rgba(255,255,255,.2); border-top-color: var(--text); border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle; margin-right: 6px; }
const btn = document.getElementById('btn-state');
btn.addEventListener('click', () => {
if (btn.classList.contains('loading')) return;
btn.classList.replace('idle', 'loading');
btn.innerHTML = '<span class="btn-spinner"></span>Submitting…';
setTimeout(() => {
btn.classList.replace('loading', 'success');
btn.innerHTML = '✓ Submitted!';
setTimeout(() => {
btn.classList.replace('success', 'idle');
btn.innerHTML = 'Submit form';
}, 2000);
}, 1800);
});
Icon + label swap confirms the action instantly
navigator.clipboard + class swapA copy button that confirms success by swapping icon and label — instant visual feedback without a toast.
.copy-block { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; width: 100%; }
.copy-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 10px; color: var(--muted); }
.copy-btn { background: none; border: 1px solid var(--border); color: var(--muted); font-family: 'DM Mono', monospace; font-size: 10px; padding: 3px 8px; border-radius: 4px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 4px; }
.copy-btn.copied { border-color: var(--c5); color: var(--c5); }
.copy-code { padding: 12px; font-size: 11px; color: var(--c4); line-height: 1.9; }
const btn = document.getElementById('copy-btn');
const icon = document.getElementById('copy-icon');
const text = document.getElementById('copy-text');
btn.addEventListener('click', () => {
navigator.clipboard?.writeText('your-code-here').catch(() => {});
btn.classList.add('copied');
icon.textContent = '✓';
text.textContent = 'Copied!';
setTimeout(() => {
btn.classList.remove('copied');
icon.textContent = '⎘';
text.textContent = 'Copy';
}, 2000);
});
Slides in from below, auto-dismisses after 3s
translateY + opacity + setTimeoutNotification messages that slide up from below and auto-dismiss — non-blocking feedback.
.toast-stage { width: 100%; height: 160px; display: flex; align-items: center; justify-content: center; position: relative; }
.toast-stack { position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none; }
.toast { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 9px 14px; font-size: 12px; display: flex; align-items: center; gap: 9px; white-space: nowrap; transform: translateY(16px); opacity: 0; transition: transform 0.35s cubic-bezier(0.16,1,0.3,1), opacity 0.35s; }
.toast.show { transform: none; opacity: 1; }
.toast-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
const toastTypes = [
{ msg: 'Changes saved successfully', color: 'var(--c5)', label: 'Success' },
{ msg: 'Link copied to clipboard', color: 'var(--c2)', label: 'Info' },
];
function showToast(type) {
const stack = document.getElementById('toast-stack');
const el = document.createElement('div');
el.className = 'toast';
el.innerHTML = `<div class="toast-dot" style="background:${type.color}"></div><strong style="color:${type.color}">${type.label}</strong> ${type.msg}`;
stack.appendChild(el);
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
setTimeout(() => {
el.classList.remove('show');
setTimeout(() => el.remove(), 400);
}, 3000);
}
Spring thumb with track colour transition
cubic-bezier(0.34, 1.56, ...) springCustom checkbox-based toggles with spring easing on the thumb and colour transition on the track.
.tog { position: relative; width: 44px; height: 24px; }
.tog input { opacity: 0; width: 0; height: 0; }
.tog-track { position: absolute; inset: 0; background: var(--border); border-radius: 12px; cursor: pointer; transition: background 0.3s; }
.tog-thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: var(--muted); transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), background 0.3s; }
.tog input:checked ~ .tog-track { background: var(--c5); }
.tog input:checked ~ .tog-track .tog-thumb { transform: translateX(20px); background: #fff; }
<label class="tog">
<input type="checkbox" checked>
<div class="tog-track">
<div class="tog-thumb"></div>
</div>
</label>
Interactions that reveal, compare, sort, and expand — making dense content explorable. Card flips, before/after sliders, accordions, and filter tabs.
Click to flip
The visual language — spacing, type, colour.
Click to flip
Implementation — clean, composable, tested.
Click to flip
Deploy with confidence. Measure what matters.
rotateY 180° reveals the back face on click
preserve-3d + backface-visibilityCards that rotate 180° in 3D space on click — reveal hidden content on the back.
.flip-row { display: flex; gap: 14px; width: 100%; perspective: 900px; }
.flip-card { flex: 1; aspect-ratio: 0.85; position: relative; transform-style: preserve-3d; transition: transform 0.65s cubic-bezier(0.4,0,0.2,1); cursor: pointer; }
.flip-card.flipped { transform: rotateY(180deg); }
.flip-face { position: absolute; inset: 0; border-radius: 10px; backface-visibility: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 14px; text-align: center; }
.flip-front { background: var(--surface2); border: 1px solid var(--border); }
.flip-back { background: var(--surface); transform: rotateY(180deg); }
document.querySelectorAll('.flip-card').forEach(card => {
card.addEventListener('click', () => {
card.classList.toggle('flipped');
});
});
Drag the handle to compare two states
clip-path: inset(0 X% 0 0) on dragInteractive comparison slider — drag a handle to reveal before/after states side by side.
.ba-wrap { width: 100%; height: 180px; border-radius: 8px; overflow: hidden; border: 1px solid var(--border); position: relative; cursor: ew-resize; user-select: none; }
.ba-before { position: absolute; inset: 0; background: linear-gradient(135deg, #1e1e3f 0%, #2d1b69 100%); display: flex; align-items: center; justify-content: center; font-size: 11px; color: rgba(255,255,255,.5); }
.ba-after { position: absolute; inset: 0; background: linear-gradient(135deg, #0d4a2a 0%, #1a7a40 100%); display: flex; align-items: center; justify-content: center; font-size: 11px; color: rgba(255,255,255,.5); clip-path: inset(0 50% 0 0); }
.ba-handle { position: absolute; top: 0; bottom: 0; left: 50%; transform: translateX(-50%); width: 2px; background: rgba(255,255,255,.8); display: flex; align-items: center; justify-content: center; pointer-events: none; }
.ba-grip { width: 30px; height: 30px; border-radius: 50%; background: #fff; display: flex; align-items: center; justify-content: center; font-size: 13px; color: #0d0d0d; box-shadow: 0 2px 8px rgba(0,0,0,.3); }
const baWrap = document.getElementById('ba-wrap');
const baAfter = document.getElementById('ba-after');
let dragging = false;
function updateBA(x) {
const r = baWrap.getBoundingClientRect();
const pct = Math.max(5, Math.min(95, ((x - r.left) / r.width) * 100));
baAfter.style.clipPath = `inset(0 ${100 - pct}% 0 0)`;
baHandle.style.left = pct + '%';
}
baWrap.addEventListener('mousedown', () => { dragging = true; });
window.addEventListener('mousemove', e => { if (dragging) updateBA(e.clientX); });
window.addEventListener('mouseup', () => { dragging = false; });
max-height: 0 → auto with cubic-bezier ease
max-height transition + overflow:hiddenExpandable sections that collapse and expand smoothly — only one open at a time.
.accordion { width: 100%; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.acc-item { border-bottom: 1px solid var(--border); }
.acc-item:last-child { border-bottom: none; }
.acc-trigger { width: 100%; background: none; border: none; color: var(--text); font-family: 'DM Mono', monospace; font-size: 12px; padding: 13px 16px; text-align: left; cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: background 0.2s; }
.acc-trigger:hover { background: var(--surface2); }
.acc-icon { color: var(--muted); font-size: 18px; transition: transform 0.3s ease; line-height: 1; }
.acc-item.open .acc-icon { transform: rotate(45deg); }
.acc-body { max-height: 0; overflow: hidden; transition: max-height 0.35s cubic-bezier(0.4,0,0.2,1); font-size: 11px; color: var(--muted); padding: 0 16px; }
.acc-item.open .acc-body { max-height: 80px; padding-bottom: 13px; }
document.querySelectorAll('.acc-trigger').forEach(btn => {
btn.addEventListener('click', () => {
const item = btn.closest('.acc-item');
const wasOpen = item.classList.contains('open');
// Close all items
document.querySelectorAll('.acc-item').forEach(i => i.classList.remove('open'));
// Open if wasn't open
if (!wasOpen) item.classList.add('open');
});
});
Category filter with fade-out on hidden items
opacity + scale on .hidden classCategory filter buttons that show/hide grid items based on selected category.
.filter-tabs { display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap; }
.fil-tab { background: none; border: 1px solid var(--border); color: var(--muted); font-family: 'DM Mono', monospace; font-size: 10px; padding: 4px 12px; border-radius: 20px; cursor: pointer; transition: all 0.2s; }
.fil-tab.active { background: var(--c6); border-color: var(--c6); color: var(--bg); }
.filter-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.fil-item { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 10px 8px; font-size: 10px; text-align: center; transition: opacity 0.3s, transform 0.3s; }
.fil-item.hidden { opacity: 0; transform: scale(0.9); pointer-events: none; }
document.querySelectorAll('.fil-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.fil-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const cat = tab.dataset.cat;
document.querySelectorAll('.fil-item').forEach(item => {
item.classList.toggle('hidden', cat !== 'all' && item.dataset.cat !== cat);
});
});
});
Numbers and charts that earn attention by animating into view — not just sitting there. Bar charts, progress rings, and sticky section labels.
Bars grow from baseline on scroll entry
scaleY(0) → scaleY(1) staggeredBar chart that animates bars in sequence when scrolled into view — data feels alive.
.chart-bars { display: flex; align-items: flex-end; gap: 8px; height: 110px; }
.bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 5px; height: 100%; justify-content: flex-end; }
.bar-fill { width: 100%; border-radius: 4px 4px 0 0; transform-origin: bottom; transform: scaleY(0); transition: transform 0.8s cubic-bezier(0.16,1,0.3,1); position: relative; }
.bar-fill.visible { transform: scaleY(1); }
.bar-val { position: absolute; top: -18px; left: 50%; transform: translateX(-50%); font-size: 9px; color: var(--muted); white-space: nowrap; }
.bar-lbl { font-size: 9px; color: var(--muted); }
new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
e.target.querySelectorAll('.bar-fill').forEach((b, i) => {
setTimeout(() => b.classList.add('visible'), i * 90);
});
});
}, { threshold: 0.3 }).observe(chartParent);
SVG stroke-dashoffset draws the arc on entry
stroke-dashoffset transitionCircular progress indicators drawn with SVG stroke animation — satisfying arc reveals.
.ring-row { display: flex; gap: 20px; justify-content: center; align-items: center; flex-wrap: wrap; }
.ring-item { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.ring-svg { transform: rotate(-90deg); }
.ring-bg { stroke: var(--border); fill: none; }
.ring-fill { fill: none; stroke-linecap: round; stroke-dasharray: 251.2; stroke-dashoffset: 251.2; transition: stroke-dashoffset 1.2s cubic-bezier(0.16,1,0.3,1); }
.ring-item.visible .ring-fill { stroke-dashoffset: var(--dash, 80); }
.ring-lbl { font-size: 10px; color: var(--muted); }
new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
e.target.querySelectorAll('.ring-item').forEach((item, i) => {
setTimeout(() => item.classList.add('visible'), i * 150);
});
});
}, { threshold: 0.3 }).observe(ringParent);
Group headers pin at top as content scrolls past
position: sticky + top: 0Section headers that stick to the top as you scroll through grouped content — always know where you are.
.sticky-box { height: 190px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; width: 100%; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.sticky-group { position: relative; }
.sticky-lbl { position: sticky; top: 0; background: var(--bg); border-bottom: 1px solid var(--border); padding: 5px 12px; font-size: 9px; color: var(--c7); letter-spacing: 0.14em; text-transform: uppercase; z-index: 1; }
.sticky-row { padding: 9px 12px; font-size: 11px; color: var(--muted); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 9px; }
.sticky-row-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
<div class="sticky-box">
<div class="sticky-group">
<div class="sticky-lbl">Q1 2025</div>
<div class="sticky-row">
<div class="sticky-row-dot" style="background:var(--c5)"></div>
Item 1
</div>
</div>
</div>
The moments that make someone smile. Use sparingly — delight is earned, not assumed. Particle trails, ambient gradients, theme transitions, and confetti.
Coloured dots spawn on mousemove and fade out
createElement + animation on mousemoveColourful dots follow your cursor and fade away — pure delight with no function.
@keyframes trail-out {
0% { opacity: 0.9; }
100% { opacity: 0; transform: translate(-50%,-50%) scale(0.1); }
}
.trail-zone { width: 100%; height: 200px; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; position: relative; cursor: crosshair; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.trail-dot { position: absolute; border-radius: 50%; pointer-events: none; transform: translate(-50%,-50%); animation: trail-out 0.7s ease forwards; }
const trailZone = document.getElementById('trail-zone');
const colors = ['var(--c1)', 'var(--c2)', 'var(--c3)', 'var(--c4)', 'var(--c5)'];
let colorIdx = 0;
trailZone.addEventListener('mousemove', e => {
const r = trailZone.getBoundingClientRect();
const dot = document.createElement('div');
dot.className = 'trail-dot';
const size = Math.random() * 10 + 4;
dot.style.cssText = `width:${size}px;height:${size}px;background:${colors[colorIdx++ % colors.length]};left:${e.clientX - r.left}px;top:${e.clientY - r.top}px;`;
trailZone.appendChild(dot);
setTimeout(() => dot.remove(), 700);
});
Smooth colour transition between dark and light
background + color transition 0.5sDark and light mode toggle with smooth colour transition across all elements.
:root {
--bg: #0d0d0d;
--text: #f0ece4;
}
[data-theme="light"] {
--bg: #f5f2ec;
--text: #1a1814;
}
* {
transition-property: background-color, border-color, color;
transition-duration: 0.5s;
transition-timing-function: ease;
}
.theme-toggle { background: var(--surface); border: 1px solid var(--border); color: var(--text); cursor: pointer; padding: 8px 16px; border-radius: 20px; }
const toggle = document.getElementById('theme-toggle');
let isDark = true;
toggle.addEventListener('click', () => {
isDark = !isDark;
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
toggle.textContent = isDark ? 'Light mode' : 'Dark mode';
});
Gradient blobs follow cursor — atmospheric depth
mousemove → left/top of blobsBlurred gradient orbs that follow your cursor — creates depth and atmosphere.
.ambient-zone { width: 100%; height: 200px; border-radius: 8px; border: 1px solid var(--border); position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; background: var(--surface2); }
.ambient-blob { position: absolute; border-radius: 50%; filter: blur(55px); opacity: 0.55; pointer-events: none; transform: translate(-50%,-50%); transition: left 0.25s ease, top 0.25s ease; }
.ambient-hint { position: relative; z-index: 1; font-size: 12px; color: var(--muted); text-align: center; pointer-events: none; }
const ambZone = document.getElementById('ambient-zone');
const blob1 = document.getElementById('amb-blob1');
const blob2 = document.getElementById('amb-blob2');
ambZone.addEventListener('mousemove', e => {
const r = ambZone.getBoundingClientRect();
const x = ((e.clientX - r.left) / r.width) * 100;
const y = ((e.clientY - r.top) / r.height) * 100;
blob1.style.left = x + '%';
blob1.style.top = y + '%';
blob2.style.left = (100 - x * 0.7) + '%';
blob2.style.top = (100 - y * 0.7) + '%';
});
Particles explode from click point with physics
rAF + random translate/rotateCelebration particles explode from click with arc and spin — pure joy in animation.
@keyframes conf-fall {
0% { opacity: 1; transform: translate(0,0) rotate(0deg); }
100% { opacity: 0; transform: translate(var(--tx), var(--ty)) rotate(var(--rot)); }
}
.confetti-stage { width: 100%; height: 200px; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.confetti-p { position: absolute; pointer-events: none; animation: conf-fall var(--dur) ease-out forwards; border-radius: 2px; }
document.getElementById('confetti-btn').addEventListener('click', (e) => {
const stage = document.getElementById('confetti-stage');
const r = stage.getBoundingClientRect();
const colors = ['var(--c1)','var(--c2)','var(--c3)','var(--c4)','var(--c5)'];
for (let i = 0; i < 40; i++) {
const p = document.createElement('div');
p.className = 'confetti-p';
const size = Math.random() * 8 + 4;
const tx = (Math.random() - 0.5) * 240;
const ty = Math.random() * 160 + 60;
const rot = (Math.random() - 0.5) * 720;
const dur = (Math.random() * 0.5 + 0.6) + 's';
p.style.cssText = `width:${size}px;height:${size}px;background:${colors[i%colors.length]};left:${e.clientX - r.left}px;top:${e.clientY - r.top}px;--tx:${tx}px;--ty:${ty}px;--rot:${rot}deg;--dur:${dur};`;
stage.appendChild(p);
setTimeout(() => p.remove(), 1200);
}
});