Skip to content

Example: Price + Product, before / after cashback

Manifest row 15 · built today as betta-cashback-price-stack

A yellow promo pill ("Bonus $200 Cashback*") over two stacked price boxes: a white "Before Cashback" box with "$1088" in navy, then a red "After Cashback" box with "$888" in white. Every box is the same fixed width regardless of how long its text is; a shorter label never shrinks the box under it. Each panel wipes on from the left, staggered by four frames, and the promo pill pulses.

flowchart TB
    subgraph scene["scene-price-product  (one motion graph per ratio)"]
        ROOT["cps-root<br/>flowBox column · absolute · w 309 · alignAcross stretch"]
        PROMO["cps-promo<br/>text + own pill · flow · wipe, pulse"]
        BEFORE["cps-before<br/>flowBox column · flow (stretched)"]
        BL["cps-before-label<br/>text · flow"]
        BA["cps-before-amount<br/>text (price run) · flow"]
        BBG["cps-before-bg<br/>background · derived"]
        AFTER["cps-after<br/>flowBox column · flow (stretched)"]
        AL["cps-after-label<br/>text · flow"]
        AA["cps-after-amount<br/>text (price run) · flow"]
        ABG["cps-after-bg<br/>background · derived"]
        PROMO -- flowIn --> ROOT
        BEFORE -- flowIn --> ROOT
        AFTER -- flowIn --> ROOT
        BL -- flowIn --> BEFORE
        BA -- flowIn --> BEFORE
        AL -- flowIn --> AFTER
        AA -- flowIn --> AFTER
        BBG -- of --> BEFORE
        ABG -- of --> AFTER
    end

The document

16:9 shown in full; 9:16 and 1:1 carry the same shape with their own numbers. The design's panel width is 349px. Because padding is paint-only and grows the pill outward from the box layout reads, the root is authored 309 wide and every panel carries 20px of side padding, so the visible pills come out at 349 and start at x: 120.

{
  "id": "scene-price-product",
  "durationInFrames": 75,
  "layers": [

    // ── The column. Fixed width by design: an explicit `w`, never "content", so a short label
    //    can't narrow it. `stretch` hands that width to every member.
    {
      "id": "cps-root",
      "type": "flowBox",
      "zIndex": 1,
      "layout": {
        "mode": "absolute",
        "16:9": { "x": 140, "y": 120, "w": 309, "h": "content", "anchor": "top-left" },
        "9:16": { /* … */ },
        "1:1":  { /* … */ }
      },
      "props": { "direction": "column", "alignAcross": "stretch", "alignAlong": "top", "gap": 17 }
    },

    // ── Promo pill. One text layer, three runs: prose, an inline price run, prose. Its own
    //    paint-only pill fills the stretched box plus padding. The only part that pulses.
    {
      "id": "cps-promo",
      "type": "text",
      "zIndex": 3,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "cps-root", "w": "content", "h": "content" },
        "9:16": { /* … */ }, "1:1": { /* … */ }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 60, "color": "#054F8E",
                  "tracking": -0.2, "horizontalAlign": "left" }
      },
      "props": {
        "content": [
          { "value": "Bonus " },
          { "price": { "amount": 200, "currency": "AUD", "locale": "en-AU", "grouping": false } },
          { "value": " Cashback*" }
        ],
        "patterns": [
          { "expression": "\\*$", "typography": { "size": { "ofRoot": 0.58 }, "baseline": 0.45 } }
        ],
        "background": { "fill": "#FFD007", "radius": 8,
                        "padding": { "top": 26, "right": 20, "bottom": 25, "left": 20 } }
      },
      "animations": [
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 0 } },
        { "animation": "pulse", "params": { "scale": 1.04, "periodInFrames": 40, "delay": 30 } }
      ]
    },

    // ── "Before Cashback" box: a stretched inner column (label over amount, 8px apart) with a
    //    derived white background behind it. The background is what the eye reads as "the box".
    {
      "id": "cps-before",
      "type": "flowBox",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "cps-root", "w": "content", "h": "content" },
        "9:16": { /* … */ }, "1:1": { /* … */ }
      },
      "props": { "direction": "column", "alignAcross": "left", "alignAlong": "top", "gap": 8 },
      "animations": [
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 4 } }
      ]
    },
    {
      "id": "cps-before-label",
      "type": "text",
      "zIndex": 3,
      "layout": { "mode": "flow", "16:9": { "flowIn": "cps-before", "w": "content", "h": "content" },
                  "9:16": { /* … */ }, "1:1": { /* … */ } },
      "typography": { "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 33,
                                "color": "#054F8E", "tracking": 0.3 } },
      "props": { "content": [ { "value": "Before Cashback" } ] }
    },
    {
      "id": "cps-before-amount",
      "type": "text",
      "zIndex": 3,
      "layout": { "mode": "flow", "16:9": { "flowIn": "cps-before", "w": "content", "h": "content" },
                  "9:16": { /* … */ }, "1:1": { /* … */ } },
      "typography": { "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 152,
                                "color": "#054F8E", "tracking": 1.5 } },
      "props": {
        "content": [
          {
            "price": { "amount": 1088, "currency": "AUD", "locale": "en-AU", "grouping": false },
            "typography": {
              "currencySymbol": { "size": { "ofRoot": 0.585 }, "baseline": "super", "tracking": 8 }
            }
          }
        ]
      }
    },
    {
      "id": "cps-before-bg",
      "type": "background",
      "zIndex": 1,
      "layout": {
        "mode": "derived",
        "16:9": { "of": ["cps-before"], "ofBounds": "extents" },
        "9:16": { /* … */ }, "1:1": { /* … */ }
      },
      "props": { "fill": "#FFFFFF", "radius": 8,
                 "padding": { "top": 16, "right": 20, "bottom": 12, "left": 20 } },
      // `of` carries geometry only, so the entrance has to be restated here with the same timing.
      "animations": [
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 4 } }
      ]
    },

    // ── "After Cashback" box: identical structure, red panel, white type, +8 frame stagger.
    {
      "id": "cps-after",
      "type": "flowBox",
      "zIndex": 2,
      "layout": { "mode": "flow", "16:9": { "flowIn": "cps-root", "w": "content", "h": "content" },
                  "9:16": { /* … */ }, "1:1": { /* … */ } },
      "props": { "direction": "column", "alignAcross": "left", "alignAlong": "top", "gap": 8 },
      "animations": [
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 8 } }
      ]
    },
    {
      "id": "cps-after-label",
      "type": "text",
      "zIndex": 3,
      "layout": { "mode": "flow", "16:9": { "flowIn": "cps-after", "w": "content", "h": "content" },
                  "9:16": { /* … */ }, "1:1": { /* … */ } },
      "typography": { "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 33,
                                "color": "#FFFFFF", "tracking": 0.3 } },
      "props": { "content": [ { "value": "After Cashback" } ] }
    },
    {
      "id": "cps-after-amount",
      "type": "text",
      "zIndex": 3,
      "layout": { "mode": "flow", "16:9": { "flowIn": "cps-after", "w": "content", "h": "content" },
                  "9:16": { /* … */ }, "1:1": { /* … */ } },
      "typography": { "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 152,
                                "color": "#FFFFFF", "tracking": 1.5 } },
      "props": {
        "content": [
          {
            "price": { "amount": 888, "currency": "AUD", "locale": "en-AU", "grouping": false },
            "typography": {
              "currencySymbol": { "size": { "ofRoot": 0.585 }, "baseline": "super", "tracking": 8 }
            }
          }
        ]
      }
    },
    {
      "id": "cps-after-bg",
      "type": "background",
      "zIndex": 1,
      "layout": { "mode": "derived", "16:9": { "of": ["cps-after"], "ofBounds": "extents" },
                  "9:16": { /* … */ }, "1:1": { /* … */ } },
      "props": { "fill": "#FE2121", "radius": 8,
                 "padding": { "top": 17, "right": 20, "bottom": 10, "left": 20 } },
      "animations": [
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 8 } }
      ]
    }
  ]
}

What a campaign would bind:

Path Control
cps-promo.props.content[1].price.amount number (the bonus)
cps-promo.props.content[0].value, [2].value text
cps-before-amount.props.content[0].price.amount number, from product data
cps-after-amount.props.content[0].price.amount number, from product data
cps-before-label / cps-after-label .props.content[0].value text
every props.background.fill / props.fill colour

What the engine does with it

Resolve. Three price runs go through one cached Intl.NumberFormat("en-AU", { currency: "AUD" }) each, producing $ and 1088 parts with no cents (whole amounts) and no grouping separator. The \*$ pattern matches the promo's concatenated run text. Every at resolves against this scene's start; wipe and pulse expand into tracks.

Solve. Topological order: the six leaf texts, then cps-before and cps-after (each packs label over amount, 8px apart, left aligned), then cps-root (packs three members top to bottom, 17px apart, and stretches each to its own 309px width), then the two derived backgrounds (each takes its inner column's extents — 309 × the column's height — plus padding). Measure is called for each text with no width constraint, since every text is "content" on both axes and only the containers are stretched.

Animate. Each panel's wipe clips it in from the left on its own stagger. The promo pulses about its own content centre and its pill goes with it because the pill is its own paint. The derived backgrounds re-derive from their columns' animated boxes every frame and run their own restated wipe.

Place. Nine positioned elements. The two backgrounds paint under everything, the texts over.

What this shows

  • Fixed length is one explicit number. w: 309 on the root plus stretch is the whole "don't shrink if the top line is shorter" rule. Nothing else needed.
  • Two ways to make a panel. The promo's pill is the text layer's own paint-only background; the price boxes are derived backgrounds over a two-line column, because label-over-amount with an exact 8px gap is two layers, not two lines.
  • An inline price inside prose ("Bonus $200 Cashback*") is three runs, with the bindable amount a number in the middle.

Stress points

  • Wipe is not a transform or an opacity. Every panel here "wipes on", the notes ask for it on almost every component, and the built code does it with an animated clip-path. The motion graph's animatable set is position/scale/rotation/opacity only (Motion Graph → What animates), so a wipe named animation has nothing to expand into. Either clip becomes a fifth node value or wipe stays outside the graph as an effect/transition, and the brief has to say which.
  • A derived background can't inherit its members' entrance. of carries geometry only (Motion Graph → Opacity along edges), so each price box's wipe is written twice — once on the inner column, once on its background — and they can drift. The lockup page's "one animation, three inheritance paths" only holds for transform and opacity.
  • Paint-only padding makes authored width ≠ visible width. The designer measures the pill at 349; the layer that layout reads is 309 because padding grows the pill outward (Text Foundation → padding is paint-only). Worse, under stretch every member gets the same box width, so pills with different side paddings come out different widths. The preset's 29/30, 14/20 and 20/20 side paddings had to be flattened to 20 everywhere to keep the three pills equal. Not a bug in the model, but a real authoring papercut the brief doesn't acknowledge.
  • A container's bounds never equal its visible extent when its panels are derived layers. cps-root's content is the union of its members' extents (the inner columns, 309 wide) and its extents is its authored box (also 309). The pills at 349 belong to non-member derived layers and are invisible to both. A badge pinned to cps-root top-right lands 20px inside the visible panel edge. (Coordinate System → Bounds, Positioning → flowBox bounds note.)
  • stretch versus an authored member w is undefined. Members here author w: "content" and rely on stretch to override it; the brief never says what happens when a stretched member has w: 200 (CSS: stretch applies only when the cross size is auto) (Positioning → flowBox vocabulary).
  • No single-line shrink-to-width. $10,888 at 152px overflows a 309px auto-height box; the only shape with shrink is number/number, and overflow applies only there (Text Foundation → point/box consolidation). A fixed-width, content-height, single-line price that scales down to fit is the price box's actual behaviour today (FitText) and isn't expressible.
  • Price runs need a grouping switch. Betta writes $1088, not $1,088; Intl groups by default and the Price page's formatter options don't include useGrouping (Price). grouping: false above is invented.
  • flowBox has no padding. A fixed-width container that insets its content has to fake it with a derived background's outward padding, as here, or spacer members. CSS containers have padding; ours only has gap (Positioning → flowBox vocabulary).
  • Patterns over mixed runs. \*$ is matched against "the concatenation of every run's value"; with a price run in the middle it's unstated whether the concatenation includes the formatted price text or a placeholder (Text Foundation → content-item text runs).