Skip to content

Example: the discount lockup

The hard case that touches the most of the system at once, written as the JSON an author would actually save — imagining every Feature in this brief built exactly as designed. Five layers, no React, no bespoke component.

What it looks like: a red pill sitting at the bottom-centre of the frame, SAVE in bold beside a large $200*, the top of SAVE level with the top of the digits, a small yellow LIMITED TIME badge hanging off the pill's top-right corner. The whole assembly pops in together six frames after the scene starts.

flowchart TB
    subgraph scene["scene-offer  (one motion graph per ratio)"]
        ROW["lockup-row<br/>flowBox · absolute · anchor bottom-centre<br/>pop animation"]
        PRE["lockup-prefix<br/>text 'SAVE' · flow"]
        HERO["lockup-hero<br/>text $200* · flow"]
        BG["lockup-bg<br/>background · derived · fade animation"]
        BADGE["lockup-badge<br/>text · pin full"]
        PRE -- "flowIn" --> ROW
        HERO -- "flowIn" --> ROW
        BG -- "of" --> ROW
        BADGE -- "pinTo" --> BG
    end

The document

Per-ratio blocks are shown in full for 16:9; 9:16 and 1:1 carry the same shape with their own numbers.

{
  "id": "scene-offer",
  "durationInFrames": 90,
  "layers": [

    // ── The pill behind everything. Its box is never authored: it's the union of
    //    lockup-row's extents plus its own paint-time padding.
    {
      "id": "lockup-bg",
      "type": "background",
      "zIndex": 0,
      "layout": {
        "mode": "derived",
        "16:9": { "of": ["lockup-row"], "ofBounds": "extents" },
        "9:16": { "of": ["lockup-row"], "ofBounds": "extents" },
        "1:1":  { "of": ["lockup-row"], "ofBounds": "extents" }
      },
      "props": { "fill": "#E4002B", "padding": { "x": 40, "y": 24 }, "radius": 16 },
      "animations": [
        { "animation": "fade", "params": { "from": 0, "durationInFrames": 18 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 6 } }
      ]
    },

    // ── The row. Content-sized on both axes, so it's exactly as wide as SAVE + gap + $200*.
    //    x/y place its bottom-centre anchor; it grows upward and outward from there.
    {
      "id": "lockup-row",
      "type": "flowBox",
      "zIndex": 1,
      "layout": {
        "mode": "absolute",
        "16:9": { "x": 960, "y": 940,  "w": "content", "h": "content", "anchor": "bottom-centre" },
        "9:16": { "x": 540, "y": 1560, "w": "content", "h": "content", "anchor": "bottom-centre" },
        "1:1":  { "x": 540, "y": 940,  "w": "content", "h": "content", "anchor": "bottom-centre" }
      },
      "props": { "direction": "row", "alignAcross": "capHeight", "gap": 20 },
      "animations": [
        { "animation": "pop", "params": { "from": 0.6, "durationInFrames": 18 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 6 } }
      ]
    },

    // ── First member. Array order is flow order: SAVE comes first.
    {
      "id": "lockup-prefix",
      "type": "text",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "lockup-row", "w": "content", "h": "content" },
        "9:16": { "flowIn": "lockup-row", "w": "content", "h": "content" },
        "1:1":  { "flowIn": "lockup-row", "w": "content", "h": "content" }
      },
      "typography": {
        "16:9": { "family": "Brand Sans", "weight": 800, "size": 72, "colour": "#FFFFFF" }
        // "9:16": …, "1:1": …
      },
      "props": { "content": [ { "value": "SAVE" } ] }
    },

    // ── Second member. One text layer, three runs: a price run and an inline suffix run.
    //    The suffix is not a pinned layer — it's just the next run on the same line.
    {
      "id": "lockup-hero",
      "type": "text",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "lockup-row", "w": "content", "h": "content" },
        "9:16": { "flowIn": "lockup-row", "w": "content", "h": "content" },
        "1:1":  { "flowIn": "lockup-row", "w": "content", "h": "content" }
      },
      "typography": {
        "16:9": { "family": "Brand Sans", "weight": 900, "size": 180, "colour": "#FFFFFF" }
      },
      "props": {
        "content": [
          {
            "price": { "amount": 200, "currency": "AUD", "locale": "en-AU" },
            "typography": {
              "currencySymbol": { "size": { "ofRoot": 0.55 } },
              "cents":          { "size": { "ofRoot": 0.5  } }
            }
          },
          { "value": "*", "typography": { "size": { "ofRoot": 0.4 } } }
        ]
      }
    },

    // ── The badge. Pinned "full" to the pill's top-right, centred on that corner,
    //    so it rides the pill's box and fades with it.
    {
      "id": "lockup-badge",
      "type": "text",
      "zIndex": 3,
      "layout": {
        "mode": "pin",
        "16:9": {
          "pinTo": "lockup-bg", "pinToBounds": "extents", "pinToAnchor": "top-right",
          "anchor": "centre-centre", "offsetX": 0, "offsetY": 0,
          "w": "content", "h": "content", "pinType": "full"
        },
        "9:16": { /* same, own offsets */ },
        "1:1":  { /* same, own offsets */ }
      },
      "typography": {
        "16:9": { "family": "Brand Sans", "weight": 700, "size": 28, "colour": "#1A1A1A" }
      },
      "props": {
        "content": [ { "value": "LIMITED TIME" } ],
        "background": { "fill": "#FFD100", "padding": { "x": 16, "y": 8 }, "radius": 999 }
      }
    }
  ]
}

What the engine does with it

Resolve. The price run becomes role-tagged parts through one Intl.NumberFormat("en-AU", { currency: "AUD" })$, 200 — with no cents part because the amount is whole (Price). Every { ofRoot } multiplies out against the hero's root size (0.55 × 180 = 99 for the symbol, 72 for the asterisk). Both at references resolve against this scene's own start cue: frame 6 (Cues). Both named animations expand into keyframe tracks — pop into a scale track from 0.6 to 1 and an opacity track from 0 to 1, fade into opacity alone (Keyframe Animation).

Solve. One topological sort over the graph above gives the order: lockup-prefix, lockup-hero, lockup-row, lockup-bg, lockup-badge. The two text layers are "content" on both axes, so solve calls measure for each with no width constraint: canvas measureText returns each run's ink extents, and the font-table reader supplies the cap-height and baseline for Brand Sans at each size (Text Foundation). The hero's three runs share one baseline. lockup-row then packs its two members left to right with a 20px gap, aligning them on capHeight — the top of S and the top of 2 land on the same line, whatever the ascender space above them — and its own content box is exactly that packed extent, positioned so its bottom-centre sits at (960, 940). lockup-bg takes the row's extents and grows them by its padding. lockup-badge measures its own text, then places its centre on the pill's top-right corner. Every box here is frame-invariant (Positioning, Background).

Animate. Frame 10, say. The pop track puts lockup-row at scale 0.78, opacity 0.42, about its content centre, composed with the scene's own motion into the row's matrix. lockup-prefix and lockup-hero are flow members, so each asks the graph for its transform and gets the row's matrix composed with its own (identity) — they scale and fade with the row without an animation of their own. lockup-bg re-derives its box from the row's animated extents, so the pill shrinks with the text; its opacity is its own fade track, 0.42 at this frame by construction of the same timing. lockup-badge is pinned full, so it takes the pill's matrix and multiplies in the pill's opacity: it rides the corner inward as the pill shrinks and fades with it (The Motion Graph).

Place. Five positioned elements, each with a transform: matrix3d(…) and an opacity, text runs emitted as absolutely-positioned spans at their solved coordinates. The browser rasterises glyphs and paints fills; it lays out nothing.

What this shows

  • Five layers, zero code. Today this assembly is betta-discount-box, a bespoke React component with its own padding, clipping, and price-formatting logic. Here every rule it hard-codes is a schema field.
  • One animation, three inheritance paths. The pop is authored once on the row. Its members inherit it through flow membership, the pill tracks it geometrically through of, and the badge inherits both geometry and opacity through a full pin. Nothing else was told to move.
  • Content-driven, ratio by ratio. Change the amount to $1,299.95 through a manifest binding and the hero widens, the row re-packs, the pill re-derives, the badge follows the new corner — in every ratio, with no coordinate edited.

Where a Feature page hasn't yet pinned a field this example uses (the inner shape of a price run, the parameter names of pop and fade), the example uses the simplest shape that fits and that page should adopt it.