Little Loops · Microinteraction Library

Motion with intention

8 categories, 30+ live patterns. Every animation you reach for, documented and interactive.

Scroll Navigation Cursor & Hover Loading Feedback Content Data Delight
scroll
01 — SCROLL

Scroll
Animations

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.

Clarity

Gentle entry.

Focus

Arrives cleanly.

Ease

Zero friction.

Universal

Works everywhere.

Opacity Fade

opacity 0→1 as element enters viewport

IntersectionObserver + opacity

Opacity Fade

Elements fade in as they enter the viewport — the simplest, most universal scroll entrance.

Prompt
Create a scroll-triggered opacity fade. Elements start at opacity 0 with a slight downward offset, then fade in smoothly when they enter the viewport using IntersectionObserver.
CSS
.fade-card {
  opacity: 0;
  transform: translateY(12px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}
.fade-card.visible {
  opacity: 1;
  transform: none;
}
JS
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));
Slides from the left
Slides from the right
Directional intent

Directional Slide

translateX triggers narrative direction

translateX(-60px) → 0

Directional Slide

Elements slide in from left or right on scroll entry — direction implies narrative flow.

Prompt
Create a scroll-triggered directional slide animation. Items with class "from-left" translate in from the left, "from-right" from the right. Use IntersectionObserver to trigger on viewport entry.
CSS
.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;
}
JS
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));
01First item in
02Then the next
03And the next
04Rhythm builds

Cascading Stagger

Sequential delays create reading rhythm

transition-delay × index

Cascading Stagger

List items animate in one after another — sequential delays build a reading rhythm.

Prompt
Create a staggered list entrance animation. When the parent enters the viewport, each child item animates in with an increasing delay (90ms × index) — creating a cascading rhythm.
CSS
.stag-item {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 0.5s ease, transform 0.5s ease;
}
.stag-item.visible {
  opacity: 1;
  transform: none;
}
JS
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);
Every word
rises from
the baseline
into view.

Clip / Wipe Reveal

Text rises from clipping mask — editorial feel

translateY(110%) → 0

Clip / Wipe Reveal

Text lines rise from a clipping mask on scroll entry — gives an editorial, editorial-magazine feel.

Prompt
Create a clipped text reveal animation. Wrap each line in an overflow:hidden container so text slides up from below the clip boundary when it enters the viewport. Stagger each line with a 100ms delay.
HTML
<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>
CSS
.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; }
JS
new IntersectionObserver(entries => {
  entries.forEach(e => {
    if (e.isIntersecting) e.target.classList.add('visible');
  });
}, { threshold: 0.15 }).observe(document.querySelector('.clip-reveal'));
🎯
🔮
🧩
🛸
🌱

Scale Pop

Spring easing from 75% — satisfying bounce

scale(0.75) → scale(1) spring

Scale Pop

Grid items spring up from 75% scale on scroll entry — the spring easing makes it feel physical.

Prompt
Create a scale pop entrance with spring easing. Items start at scale(0.75) and opacity 0, then spring into full size when they enter the viewport. Stagger each item by 75ms.
CSS
.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);
}
JS
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);
0%
Score
0k
Users
0×
Speed

Animated Counter

Numbers climb on entry — instant eye grab

rAF interpolation on entry

Animated Counter

Numbers count up from 0 when they enter the viewport — a reliable attention hook for stats.

Prompt
Create an animated number counter that counts up from 0 to a target value when the element enters the viewport. Use requestAnimationFrame with a cubic ease-out over 1400ms. Store the target in a data-target attribute.
HTML
<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>
JS
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);
Layers in motion

0.3× · 0.6× · fixed

Parallax Depth

Layer speed differentials suggest physical depth

scrollY × multiplier per layer

Parallax Depth

Multiple layers move at different speeds as you scroll — speed differential implies physical depth.

Prompt
Create a parallax depth effect with 2–3 layers. A background layer moves at 0.3× scroll speed, a mid layer at 0.6×, and foreground content stays fixed. Calculate offset relative to the element's center vs. viewport center.
CSS
.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; }
JS
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 });
Native browser power
Compositor thread — no JS jank
animation-range control
Entry → cover, granular timing

CSS Scroll-Driven

Zero JS — animation-timeline: view()

animation-timeline: view()

CSS Scroll-Driven

Pure CSS scroll animation — no JavaScript. Runs on the compositor thread for zero jank.

Prompt
Create a CSS-only scroll-driven animation using animation-timeline: view(). The element should rise from opacity 0 and translateY(16px) as it enters the viewport, with no JavaScript required. Use animation-range to control the entry window.
CSS
@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); }
}
02 — NAV

Navigation
& Wayfinding

Active states, progress indicators, and nav behaviors that orient users without distraction. Three patterns covering tabs, step flows, and scrollspy.

The design phase sets the visual language — spacing, type, and colour as constraints.

Animated Tab Indicator

Sliding underline tracks active tab smoothly

left + width transition on indicator

Animated Tab Indicator

A smooth underline that slides and scales to match the active tab — feels responsive without being jarring.

Prompt
Create a tab navigation with an animated indicator bar. The bar should smoothly slide and resize to match the active tab's position and width using left and width CSS transitions.
CSS
.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; }
JS
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]));
1
2
3
4
Step 1 of 4 — click to advance

Step Progress

Animated breadcrumb for multi-step flows

class toggle + line fill

Step Progress

Visual breadcrumb for multi-step flows — dots animate as you progress, lines fill behind you.

Prompt
Create a step progress indicator with numbered dots and connecting lines. Dots should have three states: upcoming (empty), active (filled), and done (checkmark). Lines between dots should fill as you progress.
CSS
.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); }
JS
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();
});
Overview

The starting point. Context before content.

Details

Specifics that build on the foundation.

Examples

Concrete patterns in the real world.

Summary

What to take away and apply tomorrow.

Scrollspy Nav

Active link updates as you scroll through sections

IntersectionObserver on sections

Scrollspy Nav

Navigation that reflects your position in scrollable content — the active link updates as sections come into view.

Prompt
Create a scrollspy navigation inside a scrollable container. As users scroll, IntersectionObserver detects which section is in view and highlights the corresponding nav link with a left border accent.
CSS
.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; }
JS
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' });
  });
});
03 — CURSOR

Cursor
& Hover

Interactions that respond to pointer presence — personality, invitation, and depth. Custom cursors, magnetic pull, 3D tilt, and text colour wipes.

Move your cursor here

Custom Cursor

Dot + lagging ring — contained to demo area

mousemove → position dot + ring

Custom Cursor

A custom cursor with a dot and lagging ring that tracks mouse movement — feels tactile and engaged.

Prompt
Create a custom cursor within a bounded zone. Show a dot that follows the cursor instantly, and a larger ring that lags behind with easing. Hide the system cursor and ensure elements stay within the zone bounds.
CSS
.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; }
JS
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';
});

Magnetic Buttons

Buttons drift toward cursor when nearby

mousemove → translate offset

Magnetic Buttons

Buttons that gravitate toward your cursor when you hover near them — playful and inviting.

Prompt
Create buttons that "drift" toward the cursor when it moves nearby. Calculate the offset from cursor to button center, scale it down by a factor (e.g., 0.28), and apply as a translate transform. Restore position with spring easing on mouseleave.
CSS
.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); }
JS
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);
  });
});
Interface

3D depth on hover

Motion

Perspective transform

3D Card Tilt

Perspective rotateX/Y from cursor position

rotateX + rotateY on mousemove

3D Card Tilt

Cards that rotate in 3D space based on cursor position — creates a sense of depth and interactivity.

Prompt
Create a 3D card that tilts toward the cursor. Calculate the cursor position relative to the card center, convert to rotation angles (e.g., 16° max rotateY, 12° max rotateX), and apply the transform. Reset on mouseleave with easing.
CSS
.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); }
JS
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);
  });
});

Text Colour Wipe

Colour reveals across text on hover via clip-path

clip-path: inset(0 100% 0 0) → 0

Text Colour Wipe

Text that changes colour with a smooth wipe from left to right on hover — elegant and understated.

Prompt
Create a link that reveals a new colour via clip-path on hover. Use a pseudo-element (::after) with the new colour and clip it from right to left. On hover, transition the clip-path to reveal the color.
CSS
.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); }
HTML
<a class="hover-link" href="#" data-text="New colour">Original text</a>
04 — LOADING

Loading
& Transitions

Perceived performance is performance. These patterns keep users oriented while content arrives — skeletons, spinners, and staggered entrances.

Skeleton Loader

Shimmer placeholders — shape before content

shimmer gradient animation

Skeleton Loader

Placeholder blocks that shimmer while content loads — telegraphs shape and structure before text arrives.

Prompt
Create skeleton placeholders that mimic the layout of real content. Add a shimmer gradient animation that sweeps left-to-right. Use CSS keyframes to move the gradient across the block repeatedly during load.
CSS
.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; }

Spinner Variants

Four loading states — ring, dual, pulse, dash

border + SVG stroke-dasharray

Spinner Variants

Four spinner styles: ring (simple), dual (two colors), pulse (dots), and SVG dash (smooth stroke animation).

Prompt
Create four spinner variants using CSS borders and SVG stroke-dasharray. Vary the effects: solid ring, two-color dual, pulsing dots, and animated SVG stroke. Each should rotate continuously at different speeds.
CSS
@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; }
🧠
⚗️
🎯

Staggered Entrance

Choreographed load sequence — feels crafted

setTimeout stagger on mount

Staggered Entrance

Cards that fade and scale in one after another on load — choreography signals intentional design.

Prompt
Create cards that animate in one at a time on page load. Start each card at opacity 0 and scale 0.9, then use setTimeout with an incrementing delay (e.g., 80ms + i * 150ms) to add the .in class that triggers the entrance animation.
CSS
.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; }
JS
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
05 — FEEDBACK

Feedback
& State

Every action deserves a response. These patterns close the loop between user intent and system reaction — button states, toasts, copy confirm, and toggles.

Button State Machine

idle → loading → success — one button, three moments

class swap + setTimeout

Button State Machine

A button that transitions through idle → loading → success states, showing spinner and checkmark as it progresses.

Prompt
Create a button with three states: idle (default), loading (spinner visible, cursor: wait), and success (checkmark). On click, swap classes and update innerHTML. After 1800ms, show success for 2000ms, then reset to idle.
CSS
.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; }
JS
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);
});
CSS
animation-timeline: view();
animation-range: entry 0% cover 50%;

Copy Confirm

Icon + label swap confirms the action instantly

navigator.clipboard + class swap

Copy Confirm

A copy button that confirms success by swapping icon and label — instant visual feedback without a toast.

Prompt
Create a copy button that uses navigator.clipboard to copy text. On success, swap the icon from ⎘ to ✓ and label from "Copy" to "Copied!", update the border/text color, then reset after 2000ms.
CSS
.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; }
JS
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);
});

Toast Notification

Slides in from below, auto-dismisses after 3s

translateY + opacity + setTimeout

Toast Notification

Notification messages that slide up from below and auto-dismiss — non-blocking feedback.

Prompt
Create a toast notification system. Build DOM elements on-the-fly with message, icon, and label. Start at opacity 0 with translateY(16px), transition to visible, auto-dismiss after 3000ms with a 400ms fade-out.
CSS
.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; }
JS
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);
}
Notifications
Dark mode
Animations
Analytics

Animated Toggles

Spring thumb with track colour transition

cubic-bezier(0.34, 1.56, ...) spring

Animated Toggles

Custom checkbox-based toggles with spring easing on the thumb and colour transition on the track.

Prompt
Create a custom toggle using a hidden checkbox input. Style the track and thumb as pseudo-elements. On :checked, apply spring easing to the thumb's translateX and change the track colour.
CSS
.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; }
HTML
<label class="tog">
  <input type="checkbox" checked>
  <div class="tog-track">
    <div class="tog-thumb"></div>
  </div>
</label>
06 — CONTENT

Content
& Showcase

Interactions that reveal, compare, sort, and expand — making dense content explorable. Card flips, before/after sliders, accordions, and filter tabs.

🎨
Design

Click to flip

The visual language — spacing, type, colour.

⚙️
Build

Click to flip

Implementation — clean, composable, tested.

🚀
Ship

Click to flip

Deploy with confidence. Measure what matters.

3D Card Flip

rotateY 180° reveals the back face on click

preserve-3d + backface-visibility

3D Card Flip

Cards that rotate 180° in 3D space on click — reveal hidden content on the back.

Prompt
Create a 3D card that flips on click. Use transform-style: preserve-3d on the card, position two faces absolutely with backface-visibility: hidden, and toggle a .flipped class that applies rotateY(180deg).
CSS
.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); }
JS
document.querySelectorAll('.flip-card').forEach(card => {
  card.addEventListener('click', () => {
    card.classList.toggle('flipped');
  });
});
Before
After

Before / After Slider

Drag the handle to compare two states

clip-path: inset(0 X% 0 0) on drag

Before / After Slider

Interactive comparison slider — drag a handle to reveal before/after states side by side.

Prompt
Create a before/after comparison. Overlay two images/content with clip-path. On mouse drag, update clip-path: inset(0 X% 0 0) based on cursor position. Include a draggable handle with visual feedback.
CSS
.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); }
JS
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; });
When it reduces cognitive load. If removing it makes the interface harder to parse, it earns its place.
300–400ms is the sweet spot. Over 500ms starts to feel sluggish. Under 100ms can feel abrupt.
Always. Wrap your animation in a media query check and provide an instant alternative.
CSS for state transitions. JS (GSAP/rAF) for scroll-linked, sequenced, or physics-driven work.

Accordion

max-height: 0 → auto with cubic-bezier ease

max-height transition + overflow:hidden

Accordion

Expandable sections that collapse and expand smoothly — only one open at a time.

Prompt
Create an accordion with collapsible items. On click, toggle an .open class on the item. Use max-height: 0 to hidden, and max-height: auto to show. Only allow one item open at a time (close others on new click).
CSS
.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; }
JS
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');
  });
});
Fade
Grid
Clip
Slide
Flex
Wipe

Filter Tabs

Category filter with fade-out on hidden items

opacity + scale on .hidden class

Filter Tabs

Category filter buttons that show/hide grid items based on selected category.

Prompt
Create a filterable grid with category tabs. On click, add .active to the tab and toggle .hidden on grid items that don't match the category. Use opacity and scale transitions for smooth fade-out.
CSS
.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; }
JS
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);
    });
  });
});
07 — DATA

Scroll-triggered
Data

Numbers and charts that earn attention by animating into view — not just sitting there. Bar charts, progress rings, and sticky section labels.

72%
Jan
88%
Feb
54%
Mar
96%
Apr
78%
May
64%
Jun

Animated Bar Chart

Bars grow from baseline on scroll entry

scaleY(0) → scaleY(1) staggered

Animated Bar Chart

Bar chart that animates bars in sequence when scrolled into view — data feels alive.

Prompt
Create an animated bar chart using IntersectionObserver. Each bar starts at scaleY(0) from the bottom. Add .visible class on scroll entry with staggered delays (90ms × index). Display value labels above each bar.
CSS
.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); }
JS
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);
75%
56%
22%

Progress Rings

SVG stroke-dashoffset draws the arc on entry

stroke-dashoffset transition

Progress Rings

Circular progress indicators drawn with SVG stroke animation — satisfying arc reveals.

Prompt
Create SVG progress rings that animate on scroll entry. Use stroke-dasharray for the circle length and stroke-dashoffset to hide initially. On IntersectionObserver entry, transition dashoffset to show the filled arc. Stagger each ring (150ms delay).
CSS
.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); }
JS
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);

Sticky Section Labels

Group headers pin at top as content scrolls past

position: sticky + top: 0

Sticky Section Labels

Section headers that stick to the top as you scroll through grouped content — always know where you are.

Prompt
Create sticky section labels in a scrollable container. Use position: sticky with top: 0 on section headers. Headers stay pinned until the next section scrolls past. Group items under each header.
CSS
.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; }
HTML
<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>
08 — DELIGHT

Delight
& Personality

The moments that make someone smile. Use sparingly — delight is earned, not assumed. Particle trails, ambient gradients, theme transitions, and confetti.

Move your cursor here

Cursor Particle Trail

Coloured dots spawn on mousemove and fade out

createElement + animation on mousemove

Cursor Particle Trail

Colourful dots follow your cursor and fade away — pure delight with no function.

Prompt
Create a particle trail that follows the cursor. On mousemove, create a div with random size (4-14px), position it at cursor coords, pick a random colour, and remove it after 700ms. Use CSS animation for fade and scale out.
CSS
@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; }
JS
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);
});

Theme Toggle

Smooth colour transition between dark and light

background + color transition 0.5s

Theme Toggle

Dark and light mode toggle with smooth colour transition across all elements.

Prompt
Create a theme toggle using data-theme attribute on the root element. Define CSS variables for both dark and light themes. Use transition: background-color, color 0.5s on * to smoothly animate all colour changes. Toggle data-theme on button click.
CSS
: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; }
JS
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';
});
Move cursor to shift the gradient

Ambient Gradient

Gradient blobs follow cursor — atmospheric depth

mousemove → left/top of blobs

Ambient Gradient

Blurred gradient orbs that follow your cursor — creates depth and atmosphere.

Prompt
Create 2-3 blurred gradient blobs that respond to cursor movement. Convert cursor position to percentage within the zone. Move blobs independently with different multipliers (e.g., 1.0× and 0.7×) for parallax effect. Apply heavy blur filter.
CSS
.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; }
JS
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) + '%';
});

Confetti Burst

Particles explode from click point with physics

rAF + random translate/rotate

Confetti Burst

Celebration particles explode from click with arc and spin — pure joy in animation.

Prompt
Create confetti that bursts from the click point. For each particle: random size (4-12px), random horizontal throw (-120 to +120px), random vertical drop (50-210px), random rotation (-360 to +360°), random duration (0.6-1.1s). Remove after animation completes.
CSS
@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; }
JS
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);
  }
});