# Stagger loading on HubSpot: the Brightbase doctrine

For Matt. This is how every page we build loads its content in, and the code that does it. The motion is the one running on brightbase.co today (the AI-Assisted Design page, its pricing module and FAQ: https://www.brightbase.co/ai-assisted-design). The queue below is stricter than that page, and every new build follows it.

Nothing here is a suggestion. The laws are pass/fail and we check them on the staging URL before sign-off.

## The law in one sentence

Everything on the page reveals the way a person reads it: top to bottom, left to right, one column at a time. Things that belong together move together. Every text block, card and icon moves the same way. Nothing ever shifts the layout, and nothing stays hidden when the animation can't run.

## The nine laws

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 (hover, focus) stay quick, 120 to 200 ms, and only on the properties that change.

## The recipe: the brightbase.co numbers

The motion numbers for headings, body and units are the ones running on brightbase.co. Buttons follow the unit recipe everywhere, 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 copy, leads, captions, table text | whole lines rising inside masks | 40 px | 0.8 s | 0.08 s per line |
| Units: cards, list rows, table rows, FAQ 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 (descender room), 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

- **A text block** is 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** is 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** is one visual block whose contents play in reading order from one trigger: a card grid, a list, a table, an accordion, a column pair. A group is never a page or section wrapper. If a group is taller than one and a half screens the script plays its items one by one, so nothing off-screen plays early.
- **Icons** are 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** belong to the row or block they sit on and arrive with it: a row's divider, a card's border, a rule between items (an `hr` reveals in its place in the order). In a table, draw the row lines on the cells with `border-collapse: separate; border-spacing: 0`. A collapsed table border is painted by the table itself, so it shows before its rows; the script warns in the console when it finds one. Full-width section borders are page structure and stay put, like the header.
- **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).

## 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. Six of them:

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

Common classes are already in the script's selector lists (`.card`, `li`, `tr`, `.hs-button`, `.cta_button`, `.grid`, `.accordion`, `.hs-form`, `[class*="hs-menu"]`). Extend the lists at the top of the script for the theme's own class names. Edit the lists; do not change the numbers.

## Install in a HubSpot theme

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:
   ```html
   <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
   <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>
   <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/SplitText.min.js"></script>
   ```
2. **Add the head snippet** (below) to 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** to the theme: `css/stagger-reveal.css` with `require_css`, `js/stagger-reveal.js` with `require_js` in the footer, after GSAP.
   ```
   {{ require_css(get_asset_url("../css/stagger-reveal.css")) }}
   {{ require_js(get_asset_url("../js/stagger-reveal.js"), "footer") }}
   ```
4. **Mark up the templates** with the six attributes: 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 attributes** so nothing shifts when they load (the triggers measure the page once).
6. **Check it** on the staging URL 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/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 on the page 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-reveal` scope, or `opacity: 0` 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 (`border-collapse: collapse`) 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 head snippet

```html
<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>
```

## The stylesheet (css/stagger-reveal.css)

```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. */
```

## The script (js/stagger-reveal.js)

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

Lineage: the Stagger Law was set on 15 September 2026 and lives in the Brightbase Designer's core as motion laws MOT-01 to MOT-09. This script is the brightbase.co hero embed v9, adapted for HubSpot: the selector lists and data attributes at the top, the editor and reduced-motion guards, the column-by-column order inside a grid, and the fail-visible gate.
