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. 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. 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. 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. 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. 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. 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. 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. 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. 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.

WhatHow it revealsRiseDurationStep between parts
Headings, eyebrows, labelswords rising inside line masks100 px1.0 s0.05 s per word
Body, leads, captions, table textwhole lines rising inside masks40 px0.8 s0.08 s per line
Units: cards, rows, stats, people, buttons, figures, rulesthe whole block rising, its lines with it, nothing split24 px0.7 sone move
The hero headingas a heading, at once when the web fonts are in100 px1.0 s0.05 s per word
The hero buttonsas units, one by one, left to right, straight after the hero text24 px0.7 s0.09 s per button
Timing ruleValue
Triggera 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 groupa 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 blocksthe next block starts 0.12 s after the last line (or a group's last item) of the block above it has started
Scrolled pasta block already above the screen, after a jump down the page, plays at once and takes no place in the queue
EasingGSAP power1.out on every tween: one easing site‑wide
Masksclip-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
Fontsthe 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 URLno 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.

AttributePut it onEffect
data-reveal="hero"the hero heading, one per pagefirst in the queue once the fonts are in; sets the recipe
data-reveal="hero-buttons"the hero's button rowits 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 wrapperreveals as one whole
data-reveal="group"a card grid, list, table, accordion, column paircontents play in reading order, column by column, from one trigger
data-reveal="skip"header, footer, forms, decoration, anything self‑animatingnever touched
data-reveal="page"the one wrapper holding the page content between header and footerhidden 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. 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. 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. 3

    Add the stylesheet and the script

    The stylesheet with require_css, the script with require_js in the footer, after GSAP.

  4. 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. 5

    Give images their width and height

    So nothing shifts when they load; the triggers measure the page once.

  6. 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.

Never

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();
})();