All examples / scroll story
Reveal on entry: feature rows shown by an IntersectionObserver
The same five feature rows as the scroll reveal, but a tiny script marks each row as it first enters the view and CSS transitions fade it in, so it works in every browser.
Prompt tested
Tamberlow · Features
Bookkeeping that keeps up with a busy stall
Five things that happen while you serve customers. Scroll on to see them.
-
Receipts in one snap
Photograph a receipt and the amount, date and shop are filed for you.
-
Bank payments sort themselves
Each payment lands in the right category before you open the app.
-
Tax totals, ready each quarter
The figures your accountant asks for, added up as you go.
-
Polite nudges for late invoices
One friendly reminder goes out a week after the due date, never more.
-
Locked with your fingerprint
Every figure stays on your phone until you choose to share it.
Prompt
Build a features section whose rows fade in and slide up once as each one enters the view, using a small IntersectionObserver script and CSS transitions so it works in every browser (no scroll event listeners, no animation libraries, no Web Animations calls), as one HTML fragment, CSS scoped under one wrapper class, and a script module that exports mount(root) returning { play, pause }. Layout: a centred column (max-width 40rem, 1.25rem side padding) on a paper background #F6F4EF with ink text #1F2A44, in the system sans-serif. Invent the product: Tamberlow, a bookkeeping app for market stalls. First a header block at least 60cqh tall with its content vertically centred: a small uppercase slate (#5B6B8C) kicker 'Tamberlow · Features', a Georgia serif h2 headline (1.75rem) and a slate lede line, so the rows start below the first screen. Then a plain list of five feature rows with 1rem gaps: each row is a white card (1px #E8ECF3 border, 0.9rem radius, a faint shadow, 1rem padding) laid out as a grid with a 2.75rem rounded-square icon tile on the left and, on the right, an h3 title (about 1.05rem) over one slate line of about 12 words. The tiles cycle sky #7FB6FF, mint #6FD6B4, sun-yellow #FFC861, sky, mint, and each holds a simple line icon drawn as inline SVG in ink with round caps (receipt, bank, bar chart, bell, padlock). Leave 30cqh of space after the list. The script: unless prefers-reduced-motion is set (or there is no IntersectionObserver), mount() adds a data-ma-armed attribute to the wrapper and observes each row (threshold 0.15); when a row intersects it gets a data-ma-shown attribute and is unobserved, so it reveals only once; any earlier row not yet shown is shown with it, so a jump down the page never leaves a row hidden above. pause() disconnects the observer and removes data-ma-armed; play() arms again and observes the rows not yet shown. The CSS: the base styles are the finished list with every row visible; only [data-ma-armed] rows without [data-ma-shown] are hidden (opacity 0, translateY(24px)), with no transition on that hidden state, so arming hides them at once. Rows with [data-ma-shown] get transition-property: opacity, transform, 0.6s, ease-out; rows that enter in the same observer callback get an increasing index in a custom property that sets transition-delay to index × 80ms. Use the transition longhands, never transition: all. Add a prefers-reduced-motion rule that sets transition: none and animation: none. Use cqh for heights, no vh units, no position: fixed and no overflow: hidden or auto. With no script, every row shows.
Words it rests on
- reveal on scroll with an IntersectionObserver (works in every browser)
- the script only sets attributes: data-ma-armed on the wrapper, data-ma-shown on a row that enters
- reveal once: unobserve each row after it is shown
- show any earlier, skipped row together with the one that enters, so a jump never leaves a row hidden above
- CSS transitions do the motion: transition-property: opacity, transform; 0.6s ease-out
- transition on the shown state only, so arming hides rows at once
- 80 ms stagger: transition-delay from an index custom property for rows that enter together
- hidden only while armed, so no script, reduced motion or pause shows every row
- mount(root) returning { play, pause }; pause() disconnects and disarms
- no scroll event listeners
- prefers-reduced-motion: no observer and transition none
Prompt test
A fresh AI session given only this prompt rebuilt this on 2026-11-05 using claude-opus-5-5; a separate fresh session using claude-opus-5-5 judged it “like this”.


Lesson: For a reveal that works in every browser, describing the script as attributes only (`data-ma-armed` on the wrapper, `data-ma-shown` on a row, unobserve after showing), hiding rows only while armed, and putting the transition on the shown state only got a rebuild with the same once-per-row reveal, the 80 ms stagger and a fully readable list without the observer, on the first judged try. Say "show any earlier row a jump skipped", or a row passed over by a jump stays hidden. The observer margin was left out of the prompt and the reveal point drifted with it.
Facts
- Tags
- scroll story intersection observer css transitions editorial harbor
- Weight
- 2.7 KB gzip (HTML + CSS + JS)
- Needs
- No library; a small JavaScript module
- Browser support
- Every browser: IntersectionObserver is in Chrome 58+, Edge 16+, Firefox 55+ and Safari 12.1+ (MDN). Without it, without script or with reduced motion, every row shows in place.
- Motion
- follows the scroll; without support or with reduced motion the finished layout shows
- Link
/examples/reveal-observer/
Source
Paste all three parts into any page. Wrap the HTML in <div class="ma-reveal-observer"> and put the CSS in a <style> element or your stylesheet; every class, id and animation name starts with ma-reveal-observer, so it will not clash with your styles.
demo.html
<section class="ma-reveal-observer-section">
<header class="ma-reveal-observer-head">
<p class="ma-reveal-observer-kicker">Tamberlow · Features</p>
<h2>Bookkeeping that keeps up with a busy stall</h2>
<p class="ma-reveal-observer-lede">Five things that happen while you serve customers. Scroll on to see them.</p>
</header>
<ul class="ma-reveal-observer-list">
<li class="ma-reveal-observer-row">
<span class="ma-reveal-observer-icon ma-reveal-observer-sky" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M7 3h10v18l-2.5-1.5L12 21l-2.5-1.5L7 21z M9.5 8h5 M9.5 12h5"/></svg></span>
<div><h3>Receipts in one snap</h3><p>Photograph a receipt and the amount, date and shop are filed for you.</p></div>
</li>
<li class="ma-reveal-observer-row">
<span class="ma-reveal-observer-icon ma-reveal-observer-mint" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 9l8-5 8 5 M6 10v7 M10 10v7 M14 10v7 M18 10v7 M4 19.5h16"/></svg></span>
<div><h3>Bank payments sort themselves</h3><p>Each payment lands in the right category before you open the app.</p></div>
</li>
<li class="ma-reveal-observer-row">
<span class="ma-reveal-observer-icon ma-reveal-observer-sun" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 20h16 M7 17v-5 M12 17V7 M17 17V9"/></svg></span>
<div><h3>Tax totals, ready each quarter</h3><p>The figures your accountant asks for, added up as you go.</p></div>
</li>
<li class="ma-reveal-observer-row">
<span class="ma-reveal-observer-icon ma-reveal-observer-sky" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M6 16v-5a6 6 0 0 1 12 0v5l1.5 2h-15z M10 20.5h4"/></svg></span>
<div><h3>Polite nudges for late invoices</h3><p>One friendly reminder goes out a week after the due date, never more.</p></div>
</li>
<li class="ma-reveal-observer-row">
<span class="ma-reveal-observer-icon ma-reveal-observer-mint" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M7 11h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2z M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>
<div><h3>Locked with your fingerprint</h3><p>Every figure stays on your phone until you choose to share it.</p></div>
</li>
</ul>
</section>
demo.css
/* Reveal on entry with an IntersectionObserver: the same five feature rows, shown by a small
script and CSS transitions, so it works in every browser. The base styles are the finished
list. Rows hide only while the script has armed the section ([data-ma-armed]) and they have
not been shown yet; with no script, no observer, reduced motion or pause, every row is in
place. The transition sits on the shown state only, so arming hides rows at once and showing
them fades them in (opacity and transform only), each about 80 ms after the one before. */
.ma-reveal-observer {
background: #F6F4EF;
color: #1F2A44;
}
.ma-reveal-observer .ma-reveal-observer-section {
max-width: 40rem;
margin: 0 auto;
padding: 0 1.25rem 30cqh;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
font-size: 1rem;
line-height: 1.5;
}
.ma-reveal-observer .ma-reveal-observer-head {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 60cqh;
padding: 2rem 0;
}
.ma-reveal-observer .ma-reveal-observer-kicker {
margin: 0 0 0.5rem;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #5B6B8C;
}
.ma-reveal-observer h2 {
margin: 0 0 0.75rem;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.75rem;
line-height: 1.2;
}
.ma-reveal-observer .ma-reveal-observer-lede {
margin: 0;
font-size: 1.05rem;
color: #5B6B8C;
}
.ma-reveal-observer .ma-reveal-observer-list {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
.ma-reveal-observer .ma-reveal-observer-row {
display: grid;
grid-template-columns: 2.75rem 1fr;
gap: 1rem;
align-items: start;
padding: 1rem 1.1rem;
border: 1px solid #E8ECF3;
border-radius: 0.9rem;
background: #FFFFFF;
box-shadow: 0 1px 2px rgba(31, 42, 68, 0.06);
}
.ma-reveal-observer .ma-reveal-observer-icon {
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 0.75rem;
}
.ma-reveal-observer .ma-reveal-observer-sky { background: #7FB6FF; }
.ma-reveal-observer .ma-reveal-observer-mint { background: #6FD6B4; }
.ma-reveal-observer .ma-reveal-observer-sun { background: #FFC861; }
.ma-reveal-observer svg {
width: 1.5rem;
height: 1.5rem;
fill: none;
stroke: #1F2A44;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.ma-reveal-observer h3 {
margin: 0.1rem 0 0.25rem;
font-size: 1.05rem;
line-height: 1.3;
}
.ma-reveal-observer .ma-reveal-observer-row p {
margin: 0;
color: #5B6B8C;
}
/* Armed by the script: rows not shown yet wait hidden and 24 px low. */
.ma-reveal-observer[data-ma-armed] .ma-reveal-observer-row:not([data-ma-shown]) {
opacity: 0;
transform: translateY(24px);
}
/* Shown: fade and rise into place; rows that enter together follow each other by 80 ms. */
.ma-reveal-observer .ma-reveal-observer-row[data-ma-shown] {
transition-property: opacity, transform;
transition-duration: 0.6s;
transition-timing-function: ease-out;
transition-delay: calc(var(--ma-reveal-observer-order, 0) * 80ms);
}
@media (prefers-reduced-motion: reduce) {
.ma-reveal-observer,
.ma-reveal-observer * {
animation: none !important;
transition: none !important;
}
}
The motion lives in a small module. Load it with <script type="module"> and start it with mount(document.querySelector('.ma-reveal-observer')); it returns { play, pause } and starts nothing when the visitor prefers reduced motion.
demo.js
// Reveal on entry: an IntersectionObserver marks each feature row with data-ma-shown the first
// time it enters the view (and any earlier row a jump skipped), and CSS transitions do the fade
// and rise. The script only sets attributes; it never animates and never listens to the scroll.
// Under prefers-reduced-motion, or where there is no IntersectionObserver, it does nothing and
// every row stays in place.
export function mount(root, options = {}) {
const mm = options.matchMedia ?? globalThis.matchMedia;
const reduced = options.reduced ?? (mm ? mm('(prefers-reduced-motion: reduce)').matches : false);
const Observer = 'IntersectionObserver' in options ? options.IntersectionObserver : globalThis.IntersectionObserver;
const rows = [...root.querySelectorAll('.ma-reveal-observer-row')];
let io;
const show = (row, order) => {
// Rows shown together get a growing order, which CSS turns into an 80 ms stagger.
row.style.setProperty('--ma-reveal-observer-order', String(order));
row.setAttribute('data-ma-shown', '');
io?.unobserve(row);
};
const reveal = (entries) => {
let last = -1;
for (const e of entries) if (e.isIntersecting) last = Math.max(last, rows.indexOf(e.target));
// Show the entering rows and any earlier row a jump skipped, so none stays hidden above.
let order = 0;
for (const row of rows.slice(0, last + 1)) if (!row.hasAttribute('data-ma-shown')) show(row, order++);
};
const play = () => {
if (reduced || !Observer || io) return;
io = new Observer(reveal, { threshold: 0.15, rootMargin: '0px 0px -8% 0px' });
root.setAttribute('data-ma-armed', '');
for (const row of rows) if (!row.hasAttribute('data-ma-shown')) io.observe(row);
};
// Paused (off screen or pause-all): stop observing and disarm, so every row shows.
const pause = () => {
io?.disconnect();
io = undefined;
root.removeAttribute('data-ma-armed');
};
play();
return { play, pause };
}
Same idea, other ways
Say 'IntersectionObserver adds an attribute, CSS transitions animate it' for a version that works in every browser and plays each row once on entry, at its own pace.
- Scroll reveal: feature rows that fade in as they enter view timeline