Scroll Chat Story — Free HTML CSS JS Snippet

Scroll Chat Story · Scroll · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Reading-band triggers
Bubbles fire inside the reader's focus zone.
Ratchet reveals
unobserve keeps the story from unhappening.
Zero-markup dots
Pseudo-elements stage the typing state.
Layout-stable resolve
Text height exists before it appears.
Tail-anchored spring
Pops grow from each bubble's corner.
Semantic sides
Two classes control alignment and color.
No per-frame JS
IntersectionObserver only fires at crossings.
Library-free
Plain HTML, CSS, and one observer.

About this UI Snippet

Scroll Chat Story — A Conversation That Unfolds as You Scroll

Screenshot of the Scroll Chat Story snippet rendered live

The scroll chat story tells a narrative — a testimonial, a support win, a product pitch — as a messaging thread whose bubbles arrive one at a time as you scroll. Each bubble first appears as typing dots, then resolves into its message with a springy pop, exactly like watching a live conversation. It's one of the most persuasive scroll patterns because readers process dialogue almost involuntarily. This snippet builds it with vanilla HTML, CSS, and an IntersectionObserver — no libraries.

Bubbles reveal in a reading band, not at the viewport edge

The observer uses rootMargin: '0px 0px -22% 0px' with threshold: 0.4, so a bubble fires when 40% of it is visible above the bottom 22% of the screen — comfortably inside the reading zone. Trigger-at-edge implementations reveal messages the reader can't see yet; pulling the trigger line up means each bubble pops exactly where eyes are focused.

One-shot reveals: conversations don't unhappen

After a message shows, observer.unobserve() releases it. Scrolling back up leaves the thread intact — re-reading a conversation where bubbles vanish and re-pop would break the "this already happened" fiction. This is a deliberate contrast with scrubbed effects: chat is narrative state, not scroll state, so the reveal is a ratchet rather than a scrub.

Typing dots via stacked pseudo-elements

Each bubble carries its own typing indicator with zero extra markup: ::before clones the bubble's background as a cover layer, and ::after renders pulsing dots on top. The message text underneath is present the whole time (real layout, real height — so nothing shifts when it "arrives"). When is-shown lands, both pseudo-elements fade out on a 0.35s delay — the bubble pops in *as dots*, and a beat later the dots dissolve into the message. Two transitions, one class.

The pop is a CSS spring from the bubble's tail

Bubbles animate from translateY(18px) scale(.86) with cubic-bezier(.34,1.56,.64,1) — an overshooting curve that reads as a message "landing." transform-origin is set to each bubble's tail corner (bottom-left for them, bottom-right for me), so the pop grows from where a chat app anchors its bubbles, matching iMessage/WhatsApp physics.

Sides are semantic classes

.scs-them and .scs-me handle alignment, color, tail radius, and origin — the message flow stays a flat list, so writing a new story is editing text, not layout. A min-height on each row prevents late-loading rows from collapsing the scroll distance the reveals depend on.

Why IntersectionObserver over scroll math

Seven bubbles at document-flow positions are exactly what IO was built for: the browser fires callbacks only at crossings, there's zero per-frame JS, and the pattern scales to a 50-message story without any performance thought.

Row min-height keeps the scroll distance the reveals need

Each .scs-msg has min-height: 70px even though its content is a single short bubble. Without a reserved minimum, short messages would occupy very little vertical space, compressing the scroll distance between reveals until several bubbles' trigger points landed inside the same viewport at once — breaking the one-at-a-time pacing the observer relies on. The fixed minimum guarantees each message gets enough scroll runway to read as its own beat in the conversation.

Customizing it

Write your own thread (alternate the two classes), add timestamps or avatars per row, or slow the dots-to-text delay for longer "typing." Related: the full chat UI component, the typing indicator on its own, reveal on scroll for generic entrances, and a scroll pin story for pinned narratives.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to puzzle out the timing choices on your own. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the rootMargin is set to a negative bottom value paired with a 0.4 threshold, and why the typing dots are staged with layered pseudo-elements and a 0.35s transition delay instead of a separate DOM node. The same assistant can help optimize it — asking whether observer.unobserve after each reveal is actually necessary for performance versus just leaving the observer running, or how the approach would need to change for a fifty-message thread instead of seven. It's also great for extending the effect: ask it to add per-bubble timestamps, an avatar that scrolls in sync with the active speaker, or a variable typing delay based on message length so longer replies pause longer before resolving. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a "scroll chat story" effect in plain HTML, CSS, and JavaScript using only the IntersectionObserver API — no scroll-position math, no animation libraries.

Requirements:
- A vertical thread of message rows, each containing a bubble, alternating between a "them" side (left-aligned, tail on the bottom-left) and a "me" side (right-aligned, tail on the bottom-right), with each row given a reserved minimum height so short one-line bubbles don't compress the scroll distance between messages.
- Every bubble must render its real message text in the DOM from the start (so its final height is already reserved and nothing shifts later), but visually start at zero opacity and a slightly translated, slightly scaled-down transform.
- Build a typing indicator using only CSS pseudo-elements on the bubble — a ::before that covers the bubble with its own background color, and a ::after that renders pulsing "..." dots on top — with no extra markup per message, and no JavaScript-driven dot animation.
- Create a single IntersectionObserver with a rootMargin that pulls the trigger line up into the lower-middle reading area of the viewport (not the raw viewport edge) and a threshold around 0.4, so each bubble fires while it's genuinely readable, not merely peeking into view.
- When a message intersects, add a class that (a) springs the bubble in via a CSS transition using an overshooting cubic-bezier curve anchored at the bubble's own tail corner via transform-origin, and (b) fades out the typing-dots pseudo-elements on a short delay so the dots visibly resolve into the real text a beat after the bubble pops in.
- After a message has been revealed once, call unobserve on it so scrolling back up never re-hides or re-shows it — the conversation must read as something that already happened, not a scrubbable animation.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSThe thread renders with all bubbles hidden.
  2. 2
    Scroll into the threadEach bubble pops in as typing dots.
  3. 3
    Watch the resolveDots dissolve into the message a beat later.
  4. 4
    Scroll back upThe conversation stays — reveals are one-shot.
  5. 5
    Rewrite the storyAlternate scs-them and scs-me rows with your copy.
  6. 6
    Tune the reveal bandAdjust rootMargin to move the trigger line.

Real-world uses

Common Use Cases

Testimonial stories
Real customer exchanges convert; follow with a testimonial card wall.
Product pitches
Dramatize the before/after in dialogue, then show it with scroll before after.
Support showcases
Replay a great support thread; link the real chat UI.
Onboarding narratives
Explain a flow as a conversation before sticky scroll features details it.
Interactive fiction
Chat-format stories, chaptered with a scroll pin story.
Event recaps
Tell the launch-day thread; drop in a confetti celebration card at the win.
Related: Scroll Chapter Sidebar Story
See the Scroll Chapter Sidebar Story for a related scroll pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

An IntersectionObserver watches each message with rootMargin: '0px 0px -22% 0px' and threshold: 0.4 — a bubble fires when 40% of it is visible above the bottom 22% of the viewport, comfortably inside the reading zone. The callback adds is-shown and CSS does all animation; JavaScript never runs per scroll frame.

After revealing, the observer unobserves each message, making reveals a one-way ratchet. That's intentional: a conversation is narrative state, not scroll state — bubbles vanishing while re-reading would break the fiction that the exchange already happened. For scrub-style behavior, skip the unobserve and toggle the class on both transitions.

Each bubble's ::before clones its background as a cover layer and ::after renders pulsing dots above it, while the real text sits underneath at full layout height. When is-shown lands, the bubble springs in and both pseudo-elements fade on a 0.35s delay — dots first, then message, with zero layout shift because the text's space always existed.

Two details: an overshooting cubic-bezier(.34,1.56,.64,1) spring from translateY(18px) scale(.86), and transform-origin set to each bubble's tail corner — bottom-left for received, bottom-right for sent — so growth radiates from where messaging apps anchor their bubbles. Straight fades or center-origin scales immediately read as generic.

Without min-height: 70px, short single-line bubbles would occupy very little vertical space, compressing the scroll distance between messages until several trigger points sat inside the same viewport simultaneously. That breaks the one-at-a-time pacing the observer depends on. The reserved minimum guarantees every message gets enough scroll runway to read as its own distinct beat.

Render messages from an array of { side, text } objects and register one IntersectionObserver in a mount effect — useEffect, onMounted, or ngAfterViewInit — observing refs collected from the list, disconnecting it in the cleanup. Keep is-shown as a class toggle (or per-item boolean set once) so reveals stay one-shot. Bubble styling maps cleanly to Tailwind, with the pseudo-element dots kept in a small CSS layer.