Skip to content

Example: the catalogue flag

Manifest row 19 · built today as betta-catalogue-flag

A yellow pennant hanging from the top edge of the canvas in the top-left corner: CATALOGUE in brand blue over a small, slightly tilted blue panel reading OUT NOW in white. The pennant's top is flat against the canvas edge, its bottom corners are rounded, and its bottom edge slopes up from the deep left corner to the right. The whole flag drops in from above the frame; the OUT NOW panel pulses on the shared beat.

flowchart TB
    subgraph scene["scene-flag  (one motion graph per ratio)"]
        CONTENT["flag-content<br/>flowBox · column · absolute<br/>slideFade from top"]
        HEAD["flag-headline<br/>text CATALOGUE · flow"]
        BANNER["flag-banner<br/>text OUT NOW · flow · pulse"]
        BG["flag-pennant<br/>background · derived · fade"]
        HEAD -- "flowIn" --> CONTENT
        BANNER -- "flowIn" --> CONTENT
        BG -- "of" --> CONTENT
    end

The document

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

    // ── The pennant. Derived from the copy's extents plus the flag's own inset; the top
    //    padding is the drop from the canvas edge to the headline. Radius 14 rounds every
    //    corner, so the content sits at y: 72 to push the pennant's top (72 - 86 = -14) off
    //    the canvas and hide the two top corners — see Stress points for the slant.
    {
      "id": "flag-pennant",
      "type": "background",
      "zIndex": 0,
      "layout": {
        "mode": "derived",
        "16:9": { "of": ["flag-content"], "ofBounds": "extents" },
        "9:16": { "of": ["flag-content"], "ofBounds": "extents" },
        "1:1":  { "of": ["flag-content"], "ofBounds": "extents" }
      },
      "props": {
        "fill": "#FFD100",
        "padding": { "top": 86, "right": 30, "bottom": 93, "left": 30 },
        "radius": 14
      },
      "animations": [
        { "animation": "fade", "params": { "from": 0, "durationInFrames": 14 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 0 } }
      ]
    },

    // ── The copy column. Content-sized both ways; its top-centre anchor is what the
    //    slide-down animation moves.
    {
      "id": "flag-content",
      "type": "flowBox",
      "zIndex": 1,
      "layout": {
        "mode": "absolute",
        "16:9": { "x": 300, "y": 72, "w": "content", "h": "content", "anchor": "top-centre" },
        "9:16": { "x": 200, "y": 72, "w": "content", "h": "content", "anchor": "top-centre" },
        "1:1":  { "x": 200, "y": 72, "w": "content", "h": "content", "anchor": "top-centre" }
      },
      "props": { "direction": "column", "alignAcross": "centre", "gap": 19 },
      "animations": [
        { "animation": "slideFade", "params": { "from": "top", "distance": 260, "durationInFrames": 14 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 0 } }
      ]
    },

    // ── First member: the bare headline.
    {
      "id": "flag-headline",
      "type": "text",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "flag-content", "w": "content", "h": "content" },
        "9:16": { "flowIn": "flag-content", "w": "content", "h": "content" },
        "1:1":  { "flowIn": "flag-content", "w": "content", "h": "content" }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 54, "color": "#054F8E",
                  "case": "uppercase", "horizontalAlign": "center" }
        // "9:16": …, "1:1": …
      },
      "props": { "content": [ { "value": "Catalogue" } ] }
    },

    // ── Second member: the tilted blue panel. Rotation is paint-level, so the tilt never
    //    reflows the column; the pulse scales it about its own content centre.
    {
      "id": "flag-banner",
      "type": "text",
      "zIndex": 2,
      "layout": {
        "mode": "flow",
        "16:9": { "flowIn": "flag-content", "w": "content", "h": "content", "rotation": 3.1 },
        "9:16": { "flowIn": "flag-content", "w": "content", "h": "content", "rotation": 3.1 },
        "1:1":  { "flowIn": "flag-content", "w": "content", "h": "content", "rotation": 3.1 }
      },
      "typography": {
        "16:9": { "family": "GT Eesti Display", "weight": 700, "size": 49, "color": "#FFFFFF",
                  "case": "uppercase", "horizontalAlign": "center" }
      },
      "props": {
        "content": [ { "value": "Out Now" } ],
        "background": { "fill": "#054F8E", "padding": { "top": 14, "right": 24, "bottom": 13, "left": 30 }, "radius": 2 }
      },
      "animations": [
        { "animation": "pulse", "params": { "scale": 1.05, "periodInFrames": 40 },
          "at": { "mode": "cueOffset", "cue": "start", "offsetFrames": 30 } }
      ]
    }
  ]
}
Bound field Control
flag-headline.props.content[0].value text
flag-banner.props.content[0].value text
flag-pennant.props.fill, flag-banner.props.background.fill colour
flag-headline.typography.*.color, flag-banner.typography.*.color colour
flag-banner.layout.*.rotation degrees

What the engine does with it

Resolve. Both at references are this scene's own start. slideFade expands into a y track (−260 → 0) and an opacity track; fade into opacity alone; pulse into a looping scale track (Keyframe Animation).

Solve. Order: flag-headline, flag-banner, flag-content, flag-pennant. Both text layers are content-sized so measure runs with no width constraint. The column packs them centred with a 19 px gap and its top-centre lands at (300, 72). The pennant takes the column's extents and adds its four paddings, so its top edge sits 14 px above the canvas and its width follows whatever the longer of the two words measures (Coordinate System & Sizing).

Animate. During the drop the column's matrix carries a translate; both members inherit it through flow membership, and the pennant re-derives from the column's animated extents so it drops too. Its opacity is its own fade track on the same timing. From frame 30 the banner scales about its own content centre; nothing else moves (The Motion Graph).

Place. Four elements. The canvas edge clips the pennant's off-canvas top.

What this shows

  • Width follows copy. Change Catalogue to Winter Catalogue and the pennant widens by the measured difference in every ratio, with no coordinate edited.
  • One animation, two inheritance paths. The drop is authored once on the column; members ride its matrix, the pennant tracks its box.
  • Paint-level tilt. The banner's rotation never reflows the column above or below it.

Stress points

  1. The slanted bottom edge can't be expressed. The reference pennant is a parallelogram with a flat top and a bottom edge sloping up 9.5° (the built component uses CSS skewY on an over-tall fill). background is a rounded rectangle; the transform vocabulary is position/scale/rotation only (Coordinate System & Sizing → Rotation and scale); a shape: polygon pinned with w: targetFraction 1 would track width but has no corner radius. A skew: { x, y } transform field is the natural fix — a 4×4 matrix holds it for free — and with the top edge already off-canvas the skewed rectangle is the reference shape.
  2. A derived background cannot inherit the opacity of the group it wraps. The pennant follows the column's geometry through of but not its fade, so the same timing is authored twice (The Motion Graph → Opacity along edges). Giving flowBox a paint-only background (fill/padding/radius, like text's pill) would make the common "panel around a group" case one layer that inherits everything, and would also give containers the padding they currently lack.
  3. radius is uniform. A flag wants square top corners and round bottom ones; the workaround here is hiding the top corners above the canvas edge. Per-corner radius on background (Background) is a small addition.
  4. Corner placement via targetFraction needs fraction + px. A top-right variant wants x = canvas width − 120; { value: 1, unit: "targetFraction" } gives the edge and there is no way to add a px offset to it (Coordinate System & Sizing → targetFraction). The same gap appears whenever a pinned layer wants "target width plus padding".
  5. Naming: absolute uses x/y, pin uses offsetX/offsetY. The targetFraction section says it applies to offsetX/offsetY/w/h; an absolute layer has no such fields. The brief should say x/y take it too, or unify the names.
  6. rotation on a flow member's layout block — the FlowMember type in Positioning lists flowIn/w/h/alignAcrossSelf/grow only; rotation/scale are described as universal per-ratio fields elsewhere. Assumed present here.