Brightbase · stagger loading · for HubSpot · 6 Oct 2026
Everything loads the way a person reads: top to bottom, left to right, one column at a time.
The stagger doctrine for Matt's HubSpot builds: the nine laws, the motion numbers from brightbase.co, how to mark up a HubSpot page, what we check before sign‑off, and the script that does it. This page loads with that very script, so what you see is the behaviour.
The nine laws
Pass or fail, every one. The first seven are the Stagger Law as set on 15 September; eight and nine come from the Designer's core and the script enforces them too.
- 1
Reading order, always
Elements reveal the way a person reads: top to bottom, left to right. One column completes before the next column starts. A grid never reveals row by row across columns.
- 2
Hierarchy inside a block
Label, then heading, then subline, then body, then whatever sits below. Nothing below a heading moves before the heading.
- 3
Units move together
Anything that belongs together is one reveal: a bullet icon with its line, a table or list row with its divider, an FAQ row with its divider and plus sign, a photo with its name, a label with its stat, a button with its caption, a card with its icon. Text never arrives while its icon, line or frame sits still, and a line never shows before its text: no grid of empty lines waiting for their rows.
- 4
Everything or nothing
On a page that animates, every visible text block, card and icon animates, in the same way. One static block next to animated ones reads as broken.
- 5
Same-instant arrivals cascade
Blocks that enter the screen together go in reading order: each starts about a tenth of a second after the last line (or last item) of the block above it has started, so nothing below ever moves before what is above it. Buttons in a row go one by one, left to right, straight after the text above them. Anything that enters after the queue has drained starts at once.
- 6
Never move layout
A reveal never shifts anything else on the page: no reflow when it starts, none when it ends. Masks never clip descenders once the text has landed.
- 7
One recipe per site
The hero sets it (words rising inside line masks) and everything else inherits it: headings by word, body by line, cards and rows as whole blocks. Never a second, punchier animation lower down the page.
- 8
Arrivals fail visible
With JavaScript off, inside the HubSpot editor, with "reduce motion" on, or if the script never arrives, every section renders complete. Nothing is hidden by CSS alone, and the script gives everything back if its reveal never runs.
- 9
Easing and pace
Reveals are never fast and never linear: an ease‑out with a visible deceleration, long enough to be felt. Interaction transitions stay quick, 120 to 200 ms, and only on the properties that change.
The recipe: the brightbase.co numbers
Headings, body and units move as they do on brightbase.co today; buttons follow the unit recipe, the hero's included. A different site's hero may set different numbers; then every element on that site inherits exactly those. Never two sets on one site.
| What | How it reveals | Rise | Duration | Step between parts |
|---|---|---|---|---|
| Headings, eyebrows, labels | words rising inside line masks | 100 px | 1.0 s | 0.05 s per word |
| Body, leads, captions, table text | whole lines rising inside masks | 40 px | 0.8 s | 0.08 s per line |
| Units: cards, rows, stats, people, buttons, figures, rules | the whole block rising, its lines with it, nothing split | 24 px | 0.7 s | one move |
| The hero heading | as a heading, at once when the web fonts are in | 100 px | 1.0 s | 0.05 s per word |
| The hero buttons | as units, one by one, left to right, straight after the hero text | 24 px | 0.7 s | 0.09 s per button |
| Timing rule | Value |
|---|---|
| Trigger | a block enters the queue when its top reaches 88% of the viewport height, once; at the end of the page, blocks that can never reach that line enter too |
| Inside a group | a grid, a list, a table, an accordion, a row of buttons: items 0.09 s apart, in reading order, column by column in a grid, left to right in a row |
| Between blocks | the next block starts 0.12 s after the last line (or a group's last item) of the block above it has started |
| Scrolled past | a block already above the screen, after a jump down the page, plays at once and takes no place in the queue |
| Easing | GSAP power1.out on every tween: one easing site‑wide |
| Masks | clip-path: inset(0 0 -0.3em 0) on each line while its words rise, removed when the reveal lands; the split stays in place so nothing reflows |
| Fonts | the script hides every block at DOM ready and starts when the web fonts are in, 2.5 s at the latest; a watchdog gives everything back after 8 s if the reveal never runs |
Reduced motion, the editor, no JavaScript, or ?noreveal on the URL | no reveal, everything visible (?noreveal is for QA: the page as it stands, nothing moving) |
What counts as what
Six words the script and the checker share. This grid is a group: watch it play column by column.
A text block
The innermost block‑level element that holds text: a heading, a paragraph, a caption, a table cell. Inline elements (links, strong, spans, an icon inside a sentence) are part of their block.
A unit
A block that reveals as one: cards, list items, table rows, FAQ and accordion rows, stats, people, buttons, figures. Anything inside a unit, its icon included, moves with it and is never split.
A group
One visual block whose contents play in reading order from one trigger: a card grid, a list, a table, an accordion, a column pair. Never a page or section wrapper. Taller than one and a half screens, it plays item by item.
Icons
Never on their own. An icon belongs to a unit: its card, its row, its stat. A standalone icon or image module gets data-reveal="unit" on its wrapper.
Lines
A divider, border or rule arrives with the row or block it sits on. In a table, draw row lines on the cells with border-collapse: separate and zero spacing: a collapsed border is painted by the table and shows before its rows. Section borders are page structure and stay put.
Excluded
Header, navigation, footer, forms, cookie banners, marquees, sliders and carousels, pop‑ups and modals, hidden or conditional content, and anything marked data-reveal="skip": backgrounds, decoration, anything that animates on its own.
Watch the order
Six cards with icons, numbered in the order the law demands. On a wide screen the left column completes before the right one starts; on a phone they stack in the same order. Scroll up and down to play it again.
1 · Left column, first
The icon, the title and this line are one unit: they rise together, 24 px over 0.7 s.
2 · Left column, second
0.09 s after the card above it, because the grid is a group playing from one trigger.
3 · Left column, last
The left column finishes here. Nothing on the right has moved yet.
4 · Right column, first
Only now the right column starts, from its top.
5 · Right column, second
Same move, same timing, same easing as every card before it.
6 · Right column, last
The grid is done. The page never moved while it played.
Marking up a HubSpot page
It runs sitewide from the base template. The attributes are baked into the templates and module HTML once, never set per page by an editor. Common classes are already in the script's lists; extend the lists for the theme's own names. Edit the lists, never the numbers.
| Attribute | Put it on | Effect |
|---|---|---|
data-reveal="hero" | the hero heading, one per page | first in the queue once the fonts are in; sets the recipe |
data-reveal="hero-buttons" | the hero's button row | its buttons go one by one, left to right, straight after the hero text |
data-reveal="unit" | a card, row, stat, person, button group, standalone icon or image wrapper | reveals as one whole |
data-reveal="group" | a card grid, list, table, accordion, column pair | contents play in reading order, column by column, from one trigger |
data-reveal="skip" | header, footer, forms, decoration, anything self‑animating | never touched |
data-reveal="page" | the one wrapper holding the page content between header and footer | hidden before first paint until every block holds its own hide, so nothing flashes |
Install in a HubSpot theme
Six steps. The code for steps one to three is at the bottom of this page and in the markdown file.
- 1
Load GSAP 3.13 or newer
With ScrollTrigger and SplitText (all of GSAP is free since 3.13), once, in the base template, before the reveal script. From the theme's js folder or a CDN.
- 2
Add the head snippet
In the base template's head. It lets the stylesheet hide the hero before first paint, but only when the reveal can run, and gives the hero back after 2.5 s if the script never arrives.
- 3
Add the stylesheet and the script
The stylesheet with require_css, the script with require_js in the footer, after GSAP.
- 4
Mark up the templates
The page wrapper, the hero heading and the hero buttons on every page template; unit on card, list and stat modules; group on their grids; skip on header, footer, forms and decoration.
- 5
Give images their width and height
So nothing shifts when they load; the triggers measure the page once.
- 6
Check it on staging
At 390 and 1440, against the checklist below. We run our own checker on the same URL before sign-off.
The checklist we sign off against
Each is pass or fail, at 390 and 1440.
Reading order. Every reveal follows page order; in a grid, the left column completes before the right one starts; the hero's buttons come after the hero text and before anything below them.
Hierarchy. Inside a block nothing moves before the line above it.
Units. No icon, line, rule or frame arrives at a different moment from its text.
Lines. No divider, rule or border shows before the row or block it belongs to: no grid of empty lines.
Coverage. Every visible text block, card and icon is in the reveal set. No static block anywhere; header, navigation and footer are the only exceptions.
Cascade. Each block starts 0.12 s after the last line (or last item) of the block above it started; group items 0.09 s apart; buttons one by one, left to right; a block entering after the queue has drained starts within a frame.
Layout. Zero layout shift during and after every reveal; no clipped descenders once landed; no clip‑path left on a finished block.
Fail visible. With JavaScript off, in the editor and with reduced motion on, every section renders complete.
One recipe. One easing, one duration family, one distance family across the page: the hero's numbers everywhere.
No console errors.
Never
A second animation lower down the page: a punchier card flip, a different fade, a different easing.
Per-element custom delays, hand‑tuned per section.
Animating layout properties (height, margin, padding, width). Transforms and opacity only.
Hiding content with CSS outside the
html.bb-revealscope, or opacity zero on a section in a stylesheet.Revealing a grid row by row across columns.
Buttons on their own delay, or a row of buttons rising as one block.
Collapsed table borders under rows that animate: the lines are painted by the table and show before their rows.
A fade‑only block beside rising ones, or a static block beside animated ones.
Changing the numbers for one page.
The code
The same three files Matt gets in the markdown. This page runs them.
The head snippet
<script>
(function (h) {
var ok = !(window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches) && !window.hsInEditor && !h.classList.contains("hs-inline-edit") && !/[?&]noreveal\b/.test(location.search);
if (ok) h.classList.add("bb-reveal");
setTimeout(function () { if (!window.__bbRevealOK) h.classList.remove("bb-reveal"); }, 2500);
})(document.documentElement);
</script>css/stagger-reveal.css
/* Brightbase stagger reveal: only the page wrapper, the hero and its button row are hidden by CSS, and only while html.bb-reveal is present.
The head snippet adds that class when the reveal can run and removes it if the script never arrives;
the script itself hides and reveals everything else. Nothing on the page is hidden by CSS alone. */
html.bb-reveal [data-reveal="hero"],
html.bb-reveal [data-reveal="hero-buttons"],
html.bb-reveal [data-reveal="page"] { visibility: hidden; }
/* data-reveal="page" goes on the one wrapper that holds the page's content between header and footer: it is
hidden until the script has given every block its own hide (at DOM ready), so nothing flashes before the reveal. */js/stagger-reveal.js
/* Brightbase stagger reveal, for HubSpot: the reference implementation of the STAGGER LAW (15 Sep 2026, amended 6 Oct).
Lineage: the brightbase.co/ai-assisted-design hero embed v9 (its motion numbers), adapted on 6 Oct 2026 with the
selector lists and the six data-reveal attributes at the top, the HubSpot editor and reduced-motion guards, a strict
reading-order queue (each block waits for the last line or last item of the block above it; the hero's buttons go
one by one after the hero text), column-by-column order inside a grid and left-to-right order inside a row, lines
that arrive with their rows, the early hide (every block hidden at DOM ready, so nothing flashes while the fonts
load) and the fail-visible gate (html.bb-reveal is the only thing that lets CSS hide anything, and a watchdog gives
everything back if the reveal never runs). Needs GSAP 3.13 or newer with ScrollTrigger and SplitText, loaded before
this file. Edit the lists. Do not change the numbers: the hero sets the recipe and every page inherits it. */
(function () {
"use strict";
var html = document.documentElement;
var reduced = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
var inEditor = !!window.hsInEditor || html.classList.contains("hs-inline-edit");
var ready = !!(window.gsap && window.ScrollTrigger && window.SplitText);
var off = /[?&]noreveal\b/.test(location.search); // ?noreveal: the page static, for QA
if (reduced || inEditor || off || !ready) { html.classList.remove("bb-reveal"); return; } // nothing hides, nothing moves
window.__bbRevealOK = true;
html.classList.add("bb-reveal");
gsap.registerPlugin(ScrollTrigger, SplitText);
/* ---- the lists: the only part to edit per site ------------------------------------------------ */
// never animated: site chrome, forms, anything that moves on its own, anything marked skip
var EXCLUDE = '[data-reveal="skip"],header,nav,footer,.header,.footer,' +
'[class*="site-header"],[class*="site-footer"],[class*="hs-menu"],[class*="navigation"],[class*="breadcrumb"],' +
'form,.hs-form,[class*="hs_cos_wrapper_type_form"],[class*="cookie"],[class*="marquee"],[class*="ticker"],' +
'[class*="slider"],[class*="carousel"],[class*="popup"],[class*="modal"],[hidden],[aria-hidden="true"],' +
'svg,iframe,video,script,style,noscript,template';
// units: revealed as one whole (frame, icon, lines and text together), never split into words
var UNIT = '[data-reveal="unit"],.card,li,tr,.stat,.person,.faq-row,[class*="accordion-item"],[class*="accordion_item"],' +
'figure,picture,a.button,.button,.btn,button,.hs-button,.cta_button,[class*="hs_cos_wrapper_type_icon"],hr,[role="separator"]';
// groups: one visual block whose contents play from one trigger: column by column in a grid, left to right in a row
var GROUP = '[data-reveal="group"],[data-reveal="hero-buttons"],.grid,.cards,.columns,.faq,.accordion,[class*="accordion"],ul,ol,table';
// headings reveal by word; everything else by line
var HEADING = '[data-reveal="heading"],h1,h2,h3,h4,h5,h6,.eyebrow,.pretitle,.subheading,.label';
var INLINE = { A: 1, SPAN: 1, STRONG: 1, EM: 1, B: 1, I: 1, U: 1, SUP: 1, SUB: 1, SMALL: 1, ABBR: 1, TIME: 1, CODE: 1,
KBD: 1, MARK: 1, LABEL: 1, BUTTON: 1, INPUT: 1, TEXTAREA: 1, SELECT: 1, OPTION: 1, SVG: 1, IMG: 1, BR: 1, WBR: 1 };
/* ---- the recipe: brightbase.co's motion numbers; the hero sets them, everything inherits ------ */
var R = {
ease: "power1.out", // one easing site-wide
headingRise: 100, headingDur: 1.0, headingStep: 0.05, // words rising inside line masks
bodyRise: 40, bodyDur: 0.8, bodyStep: 0.08, // lines rising inside masks
unitRise: 24, unitDur: 0.7, // a card, row, stat, button or rule: the whole block
groupStep: 0.09, // inside a group, items this far apart
gap: 0.12, // a block starts this long after the last line (or last
// item) of the block above it has started
trigger: "top 88%", // a block enters when its top reaches 88% of the viewport;
// at the page end, blocks that can never reach it enter too
fontWait: 2.5, watchdog: 8 // seconds: longest wait for the fonts; the give-back timer
};
gsap.defaults({ ease: R.ease });
/* ---- helpers --------------------------------------------------------------------------------- */
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
var is = function (el, s) { try { return el.matches(s); } catch (e) { return false; } };
var within = function (el, s) { try { return !!el.closest(s); } catch (e) { return false; } };
var inline = function (el) { return !!INLINE[String(el.tagName).toUpperCase()]; };
var hasText = function (el) { return !!el.textContent.trim(); };
var shown = function (el) { return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); };
var blockTextInside = function (el) { return $$("*", el).some(function (c) { return !inline(c) && hasText(c); }); };
var byPageOrder = function (a, b) { return a === b ? 0 : (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) ? -1 : 1; };
// what reveals: the outermost units, plus every text block (the innermost block-level element that holds text)
function collect() {
var units = $$(UNIT).filter(function (el) { return !within(el, EXCLUDE) && shown(el); });
units = units.filter(function (el) { return !units.some(function (o) { return o !== el && o.contains(el); }); });
var texts = $$("body *").filter(function (el) {
return !inline(el) && hasText(el) && !blockTextInside(el) && !within(el, EXCLUDE) && !within(el, UNIT) && shown(el);
});
return units.concat(texts).sort(byPageOrder);
}
// a line belongs to its row: a collapsed table border is painted by the table, not the row, so it would show while
// the row is still hidden. Tables that animate draw their lines on the cells with border-collapse: separate.
function checkTables() {
$$("table").forEach(function (t) {
if (within(t, EXCLUDE) || getComputedStyle(t).borderCollapse !== "collapse") return;
var lined = $$("td,th", t).slice(0, 12).some(function (c) {
var s = getComputedStyle(c);
return parseFloat(s.borderTopWidth) > 0 || parseFloat(s.borderBottomWidth) > 0;
});
if (lined && window.console) console.warn("stagger-reveal: this table collapses its borders, so its row lines show before its rows. Use border-collapse: separate; border-spacing: 0.", t);
});
}
// line masks clip while words rise, with 0.3em of descender room; the clip is removed once the reveal lands and
// the split stays in place, so the page never reflows and no descender is ever clipped afterwards
function masks(split) { return split.lines.map(function (l) { return l.parentElement; }).filter(function (m) { return m && m !== split.elements[0]; }); }
function room(split) { masks(split).forEach(function (m) { m.style.overflow = "visible"; m.style.clipPath = "inset(0 0 -0.3em 0)"; }); }
function unmask(split) { masks(split).forEach(function (m) { m.style.clipPath = "none"; }); }
// every block is hidden the moment the DOM is ready (so nothing flashes while the fonts load) and shown again
// in the same frame its own reveal starts; the watchdog gives everything back if the reveal never runs
function hide(el) { gsap.set(el, { autoAlpha: 0 }); }
function show(el) { gsap.set(el, { autoAlpha: 1 }); }
// one block's reveal, and its tail: how long after the block starts its last line (or last word line) starts
function prepare(el) {
var soft = is(el, UNIT) || /flex|grid/.test(getComputedStyle(el).display);
if (soft) return { tween: gsap.fromTo(el, { y: R.unitRise, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: R.unitDur, paused: true }), tail: 0 };
var heading = is(el, HEADING);
var split = SplitText.create(el, { type: heading ? "lines, words" : "lines", mask: "lines" });
room(split);
var tail = 0, last = split.lines[split.lines.length - 1];
if (heading && split.lines.length > 1) {
for (var i = 0; i < split.words.length; i++) if (last.contains(split.words[i])) { tail = i * R.headingStep; break; }
} else if (!heading) tail = (split.lines.length - 1) * R.bodyStep;
var tween = gsap.from(heading ? split.words : split.lines, {
y: heading ? R.headingRise : R.bodyRise, autoAlpha: 0, duration: heading ? R.headingDur : R.bodyDur,
stagger: heading ? R.headingStep : R.bodyStep, paused: true,
onStart: function () { show(el); }, onComplete: function () { unmask(split); }
});
return { tween: tween, tail: tail };
}
// reading order inside a group: in a CSS grid, consecutive unit siblings go column by column (left to right by
// column, top to bottom inside it), whatever the DOM order; in a row (flex, a list, a table) they keep DOM order,
// which is left to right, line by line
function readingOrder(list) {
var out = [], run = [];
function flush() {
if (run.length > 1 && /grid/.test(getComputedStyle(run[0].parentNode).display)) run.sort(function (a, b) {
var ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
var ca = Math.round(ra.left / 24), cb = Math.round(rb.left / 24);
return (ca - cb) || (ra.top - rb.top);
});
out = out.concat(run); run = [];
}
list.forEach(function (el) {
var unit = is(el, UNIT);
if (unit && run.length && run[0].parentNode === el.parentNode) { run.push(el); return; }
flush(); run.push(el); if (!unit) flush();
});
flush();
return out;
}
var items = [], ran = false;
function run() {
ran = true;
// the reading sequence: standalone blocks, and groups whose contents play in order from one trigger
var entries = [], byGroup = new Map();
items.forEach(function (el) {
var g = el.closest(GROUP);
if (g && g.getBoundingClientRect().height > window.innerHeight * 1.5) g = null; // a tall group plays item by item
if (!g) { entries.push({ trigger: el, els: [el], step: 0 }); return; }
var e = byGroup.get(g);
if (!e) { e = { trigger: g, els: [], step: R.groupStep }; byGroup.set(g, e); entries.push(e); }
e.els.push(el);
});
entries.forEach(function (e) {
e.els = readingOrder(e.els);
e.parts = e.els.map(prepare);
e.tail = (e.parts.length - 1) * e.step + e.parts[e.parts.length - 1].tail;
});
// the queue: a block starts R.gap after the last line (or last item) of the block above it has started, so nothing
// below ever moves before what is above it; a block entering after the queue has drained starts at once; a block
// that is scrolled past while it waits (a jump down the page) plays at once and leaves the line, so what is on
// screen never waits behind what is not
var queue = [], cursor = -1, left = entries.length;
function enter(e) {
if (e.entered) return;
e.entered = true;
queue.push(e);
queue.sort(function (a, b) { return byPageOrder(a.trigger, b.trigger); });
}
function play(e) {
left--;
e.parts.forEach(function (p, i) { gsap.delayedCall(i * e.step, function () { p.tween.play(); }); });
}
function pump() {
var now = gsap.ticker.time;
// at the very end of the page, blocks whose top can never reach the trigger line enter now
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) {
entries.forEach(function (e) { if (!e.entered && e.trigger.getBoundingClientRect().top < window.innerHeight) enter(e); });
}
for (var i = queue.length - 1; i >= 0; i--) {
if (queue[i].trigger.getBoundingClientRect().bottom < 0) play(queue.splice(i, 1)[0]);
}
while (queue.length && now >= cursor) {
var e = queue.shift();
play(e);
cursor = now + e.tail + R.gap;
}
if (!left) gsap.ticker.remove(pump);
}
gsap.ticker.add(pump);
entries.forEach(function (e) {
ScrollTrigger.create({
trigger: e.trigger, start: R.trigger, once: true,
onEnter: function () { enter(e); pump(); }
});
});
ScrollTrigger.refresh();
}
function start() {
items = collect();
items.forEach(hide);
checkTables();
// the containers the stylesheet hid before first paint (the page wrapper, the hero, the hero's button row) show
// again now that every block inside them holds its own hide; a hero that is itself a block keeps its own hide
$$('[data-reveal="page"],[data-reveal="hero"],[data-reveal="hero-buttons"]').forEach(function (c) {
if (items.indexOf(c) < 0) c.style.visibility = "visible";
});
// the fonts first, so lines break where they will stay; but never longer than fontWait
var fonts = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
var timer = new Promise(function (res) { setTimeout(res, R.fontWait * 1000); });
Promise.race([fonts, timer]).then(function () { if (!ran) run(); });
setTimeout(function () { if (!ran) { items.forEach(show); html.classList.remove("bb-reveal"); } }, R.watchdog * 1000); // give everything back
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();
})();