Skip to content

Example: Bonus Cashback, full stack

Manifest row 05 · built today as betta-bonus-cashback-stack

Three tilted panels stacked and centred on one another: Bonus in blue on a yellow panel tipped 5° clockwise, a white panel tipped the other way carrying a small two-line UP TO beside a big $300, then Cashback on a wide yellow panel. Under them, two lines of white small print. Every panel casts a soft shadow and wipes on from the left in a stagger; the three panels breathe together on a shared pulse once settled; the small print slides down and fades in separately.

flowchart TB
    subgraph scene["scene-cashback  (one motion graph per ratio)"]
        ROOT["stack-root<br/>flowBox · column · absolute (120,120)"]
        PANELS["stack-panels<br/>flowBox · column · flow · pulse"]
        FOOT["stack-footnotes<br/>flowBox · column · flow"]
        BONUS["bonus<br/>text on yellow pill · flow · wipe"]
        AMT["amount-row<br/>flowBox · row · flow · wipe"]
        PRE["amount-prefix<br/>text 'UP TO' · flow"]
        HERO["amount-hero<br/>text price run · flow"]
        AMTBG["amount-bg<br/>background · derived"]
        CASH["cashback<br/>text on yellow pill · flow · wipe"]
        FN["footnote · text · flow · slideFade"]
        TERMS["terms · text · flow · slideFade"]
        PANELS -- flowIn --> ROOT
        FOOT -- flowIn --> ROOT
        BONUS -- flowIn --> PANELS
        AMT -- flowIn --> PANELS
        CASH -- flowIn --> PANELS
        PRE -- flowIn --> AMT
        HERO -- flowIn --> AMT
        AMTBG -- of --> AMT
        FN -- flowIn --> FOOT
        TERMS -- flowIn --> FOOT
    end

The document

{
  "id": "scene-cashback",
  "durationInFrames": 75,
  "layers": [

    // ── Outer column: the panel group over the small-print group. Content-sized both ways,
    //    so its box IS the lockup. x/y place its top-left where the library template puts it.
    {
      "id": "stack-root",
      "type": "flowBox",
      "zIndex": 0,
      "layout": {
        "mode": "absolute",
        "16:9": { "x": 120, "y": 120, "w": "content", "h": "content", "anchor": "top-left" },
        "9:16": { /* own position */ },
        "1:1":  { /* own position */ }
      },
      "props": { "direction": "column", "alignAcross": "centre", "gap": 19 }
    },

    // ── The three panels. This group carries the pulse: every member inherits it through flow
    //    membership, so the panels breathe about this group's centre, in time.
    {
      "id": "stack-panels",
      "type": "flowBox",
      "zIndex": 0,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "stack-root", "w": "content", "h": "content" },
        "9:16": { "flowIn": "stack-root", "w": "content", "h": "content" },
        "1:1":  { "flowIn": "stack-root", "w": "content", "h": "content" }
      },
      "props": { "direction": "column", "alignAcross": "centre", "gap": 34 },
      "animations": [
        { "animation": "pulse", "params": { "scale": 1.05, "periodInFrames": 40, "delay": 30 } }
      ]
    },

    // ── "Bonus": one text layer; the yellow pill is its own paint-only background, so it tilts
    //    and shadows with the glyphs. Painted above the amount panel where they overlap.
    {
      "id": "bonus",
      "type": "text",
      "zIndex": 3,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": 5 },
        "9:16": { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": 5 },
        "1:1":  { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": 5 }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 158, "color": "#054f8e", "horizontalAlign": "center" }
      },
      "props": {
        "content": [ { "value": "Bonus" } ],
        "background": {
          "fill": "#ffd100", "radius": 4,
          "padding": { "top": 25, "right": 37, "bottom": 27, "left": 32 }
        }
      },
      "effects": [ { "type": "dropShadow", "color": "#000000", "opacity": 0.26, "blurPx": 4, "offsetX": 5, "offsetY": 6 } ],
      "animations": [
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 0 } }
      ]
    },

    // ── The amount panel is a row of two texts with a background derived around them.
    //    The row carries the tilt and the wipe; both members inherit them.
    {
      "id": "amount-row",
      "type": "flowBox",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": -5 },
        "9:16": { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": -5 },
        "1:1":  { "flowIn": "stack-panels", "w": "content", "h": "content", "rotation": -5 }
      },
      // "bottom" lines the ink bottom of "TO" up with the ink bottom of the digits — the
      // reference's own alignment. "baseline" would take UP's baseline, the first line.
      "props": { "direction": "row", "alignAcross": "bottom", "gap": 5 },
      "animations": [
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 4 } }
      ]
    },
    {
      "id": "amount-prefix",
      "type": "text",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "amount-row", "w": "content", "h": "content" },
        "9:16": { "flowIn": "amount-row", "w": "content", "h": "content" },
        "1:1":  { "flowIn": "amount-row", "w": "content", "h": "content" }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 53, "color": "#054f8e",
                  "case": "uppercase", "lineHeight": 1.2, "multiline": true, "horizontalAlign": "center" }
      },
      "props": { "content": [ { "value": "up\nto" } ] }
    },
    {
      "id": "amount-hero",
      "type": "text",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "amount-row", "w": "content", "h": "content" },
        "9:16": { "flowIn": "amount-row", "w": "content", "h": "content" },
        "1:1":  { "flowIn": "amount-row", "w": "content", "h": "content" }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 166, "color": "#054f8e" }
      },
      "props": {
        "content": [
          // A bindable number, never a typed "$300". The symbol sits smaller and lifted.
          { "price": { "amount": 300, "currency": "AUD", "locale": "en-AU" },
            "typography": { "currencySymbol": { "size": { "ofRoot": 0.72 }, "baseline": 0.12 } } }
        ]
      }
    },
    {
      "id": "amount-bg",
      "type": "background",
      "zIndex": 1,
      "layout": {
        "mode": "derived",
        "16:9": { "of": ["amount-row"], "ofBounds": "extents" },
        "9:16": { "of": ["amount-row"], "ofBounds": "extents" },
        "1:1":  { "of": ["amount-row"], "ofBounds": "extents" }
      },
      "props": {
        "fill": "#ffffff", "radius": 4,
        "padding": { "top": 15, "right": 11, "bottom": 8, "left": 24 }
      },
      "animations": [
        // The wipe on the row doesn't reach a derived layer's own opacity/clip, so it is repeated.
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 4 } }
      ]
    },

    // ── "Cashback": same shape as "Bonus", untilted, wider padding.
    {
      "id": "cashback",
      "type": "text",
      "zIndex": 3,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "stack-panels", "w": "content", "h": "content" },
        "9:16": { "flowIn": "stack-panels", "w": "content", "h": "content" },
        "1:1":  { "flowIn": "stack-panels", "w": "content", "h": "content" }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 154, "color": "#054f8e" }
      },
      "props": {
        "content": [ { "value": "Cashback" } ],
        "background": {
          "fill": "#ffd100", "radius": 5,
          "padding": { "top": 26, "right": 12, "bottom": 27, "left": 12 }
        }
      },
      "animations": [
        { "animation": "wipe", "params": { "direction": "from-left", "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 8 } }
      ]
    },

    // ── The small print, in its own column so it sits outside the pulse.
    {
      "id": "stack-footnotes",
      "type": "flowBox",
      "zIndex": 0,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "stack-root", "w": "content", "h": "content" },
        "9:16": { "flowIn": "stack-root", "w": "content", "h": "content" },
        "1:1":  { "flowIn": "stack-root", "w": "content", "h": "content" }
      },
      "props": { "direction": "column", "alignAcross": "centre", "gap": 3 }
    },
    {
      "id": "footnote",
      "type": "text",
      "zIndex": 4,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "stack-footnotes", "w": "content", "h": "content" },
        "9:16": { "flowIn": "stack-footnotes", "w": "content", "h": "content" },
        "1:1":  { "flowIn": "stack-footnotes", "w": "content", "h": "content" }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 400, "size": 61, "color": "#ffffff", "tracking": -0.6 }
      },
      "props": {
        "content": [ { "value": "with selected products*" } ],
        "patterns": [ { "expression": "\\*$", "typography": { "size": { "ofRoot": 0.58 }, "baseline": 0.45 } } ]
      },
      "animations": [
        { "animation": "slideFade", "params": { "from": "top", "distance": 24, "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 16 } }
      ]
    },
    {
      "id": "terms",
      "type": "text",
      "zIndex": 4,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "stack-footnotes", "w": "content", "h": "content" },
        "9:16": { "flowIn": "stack-footnotes", "w": "content", "h": "content" },
        "1:1":  { "flowIn": "stack-footnotes", "w": "content", "h": "content" }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 400, "size": 26, "color": "#ffffff", "tracking": -0.3 }
      },
      "props": { "content": [ { "value": "*Via Digital Visa Gift Card. Via redemption. T&Cs apply." } ] },
      "animations": [
        { "animation": "slideFade", "params": { "from": "top", "distance": 10, "durationInFrames": 12 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 18 } }
      ]
    }
  ]
}

What the notes ask an author to reach, and where it binds:

Note Binding target Control
Editable text bonus.props.content[0].value, cashback.props.content[0].value, amount-prefix.props.content[0].value, footnote…, terms… text
Editable amount amount-hero.props.content[0].price.amount number
Change colours *.props.background.fill, *.typography[ratio].color colour
Pulse toggle per panel no numeric leaf to bind — see stress points
Remove "UP TO" and re-centre structural: removing amount-prefix from the row — see stress points

Panel shadows are layer effects

Each panel above carries a drop shadow in the source artwork. A shadow is a per-layer effect, applied to the whole layer (glyphs and pill together), not a field on background — see Coordinate System & Sizing. Where text and its panel need different shadows, they become two layers: a text layer and a background layer spanning it, each with its own effects.

What the engine does with it

Resolve. The price run becomes $ and 300 parts through Intl.NumberFormat("en-AU"); every { ofRoot } multiplies out against its layer's root size; each at resolves against this scene's start. The pulse, wipe, and slideFade names expand into keyframe tracks — wipe into whatever it animates (see stress points), pulse into a continuous scale track, slideFade into y and opacity tracks.

Solve. Topological order runs leaves first: the five texts measure themselves with no width constraint (all "content"), the font-table reader supplying GT Eesti's cap-height and baseline at each size. amount-row packs prefix and hero left to right on their resting boxes, bottoms aligned; amount-bg takes the row's extents and grows by its padding; stack-panels stacks bonus, row, cashback centred with a 34px gap; stack-footnotes stacks the two lines; stack-root stacks the two groups and lands its top-left at (120, 120). Rotation never enters packing.

Animate. Frame 40: stack-panels is at scale 1.025 on its pulse; bonus, the amount row, and cashback inherit that through flow membership and compose it with their own resting tilts. The prefix and hero inherit the row's tilt in turn. amount-bg re-derives from the row's animated extents. The footnotes, outside the pulsing group, sit still.

Place. Eleven positioned elements, each with a matrix3d and an opacity; text runs as absolutely positioned spans; pills and the derived panel as fills. The browser paints, lays out nothing.

What this shows

  • A tilted text panel is one layer. bonus and cashback are each a single text with a paint-only pill, so tilt, shadow, wipe, and pulse apply once and everything moves together.
  • A two-text panel needs three layers. The amount panel is a row, two texts, and a derived background — and that's where the encoding starts to strain (below).
  • Pulse is authored once and inherited. No part carries its own pulse; the group does, and the footnotes stay still simply by living in a sibling group.

Stress points

  • Panels and text "wipe on", but only transform and opacity animate. A wipe is a clip reveal, not a transform. Under Motion Graph → What animates a wipe named animation has nothing legal to expand into. Every row of this catalogue uses it. The brief needs either a clip/mask property on the node (paint-only, in the layer's local frame, never affecting layout) or a statement that "wipe" is implemented as an opacity/position pair instead.
  • A derived background cannot tilt with a tilted group. amount-bg takes the union of amount-row's bounds "pushed through the matrix into canvas space" (Motion Graph → Internal representation), so behind a row rotated -5° it is an axis-aligned box around a tilted row, not a tilted panel. Three of five components in this family need a tilted multi-text panel. Either the union is taken in the members' common parent frame and the parent's matrix applied to the result, or a container gets a paint-only background of its own like text already has (Positioning → flowBox).
  • No per-member spacing in a flow. The built preset has four different vertical gaps (32, 37, 19, 3) and a 16px vertical nudge on the prefix; flowBox has one gap (Positioning → flowBox's layout vocabulary). Encoded here by splitting into three nested columns and rounding 32/37 to 34, which is a lossy workaround. A per-member margin on FlowMember (per-ratio px, affects packing) would remove it.
  • Baseline alignment is first-line only. The UP / TO stack beside a hero wants its last line's baseline on the hero's baseline (Coordinate System → anchor grid). alignAcross: "bottom" on content bounds approximates it for digits, but breaks the moment the hero has a descender ($ already dips). A lastBaseline tier, or a per-layer choice, is missing.
  • Per-part pulse toggle has no binding. The note wants a pulse on/off per panel. An animation's presence isn't a numeric leaf (Positioning → Bindings target numeric leaves only), and inherited animations can't be declined by one member. Needs either an enabled boolean on an animation entry that bindings may target, or a per-layer opt-out of an inherited animation.
  • "Remove the UP TO and re-centre" is structural. prefix.enabled: false today removes the stack and the hero re-centres. In v2 that's deleting a layer from a flow, which a binding cannot do. A layer-level hidden that also removes the layer from packing (distinct from opacity 0) would cover it and is bindable as a boolean.
  • The row's wipe doesn't reach the derived background. of edges carry geometry only (Motion Graph → Opacity along edges), so the wipe is duplicated on amount-bg with identical timing. Correct per the rules, but it's the one place an author has to keep two animations in sync by hand.
  • background.shadow does not exist yet. backgroundSchema has fill, opacity, radius, padding; every panel in this family carries a Photoshop drop shadow. Used here as a paint-only extents contributor per Coordinate System → Bounds.