---
title: Variables
description: "Parameterize compositions so the same source can render different content."
---

Variables let you declare named, typed slots in a composition and fill them at render time — from a parent composition, from the CLI, or from an API call. A card composition that takes `title` and `color` can be embedded a hundred times with a hundred different values without duplicating any HTML.

## Declaring Variables

Add `data-composition-variables` to a composition's declaration root. For a full-document composition that's the `<html>` element; for a template / fragment sub-composition (which has no `<html>` shell of its own) it's the composition root element — the `[data-composition-id]` div. Its value is a JSON array of variable declarations — one object per variable:

```html compositions/card.html
<html data-composition-variables='[
  {"id":"title",  "type":"string",  "label":"Title",  "default":"Hello"},
  {"id":"color",  "type":"color",   "label":"Color",  "default":"#111827"},
  {"id":"price",  "type":"number",  "label":"Price",  "default":0,        "unit":"$"},
  {"id":"featured","type":"boolean","label":"Featured","default":false},
  {"id":"plan",   "type":"enum",    "label":"Plan",   "default":"pro",
   "options":[{"value":"pro","label":"Pro"},{"value":"enterprise","label":"Enterprise"}]}
]'>
```

Every declaration requires four fields: `id`, `type`, `label`, and `default`. `id` must be unique within the composition.

## Variable Types

| Type | `default` value | Extra options |
|------|----------------|---------------|
| `string` | `"some text"` | `placeholder?: string`, `maxLength?: number` |
| `number` | `0` | `min?: number`, `max?: number`, `step?: number`, `unit?: string` |
| `color` | `"#rrggbb"` | — |
| `boolean` | `true` / `false` | — |
| `enum` | one of the option values | `options: [{value: string, label: string}]` |

The Studio editing UI uses `label`, `type`, and the type-specific options to render the right input widget for each variable.

## What can be a variable

Variables come in two layers. The five [declared types](#variable-types) above cover typed primitive data — strings, numbers, colors, booleans, enums. For everything else, a `string` variable holding a URL is the escape hatch: your composition reads the URL and assigns it to whatever DOM element needs it.

### Parameterizing media assets

The same composition can render different images, video clips, or audio tracks just by swapping URLs through a string variable:

```html compositions/product-card.html
<html data-composition-variables='[
  {"id":"productImage","type":"string","label":"Product image URL","default":"https://cdn.example.com/products/default.png"},
  {"id":"productName","type":"string","label":"Product name","default":"Untitled"}
]'>
  <body>
    <div data-composition-id="product-card" data-width="1920" data-height="1080" data-duration="5">
      <img class="product-img" alt="" />
      <h1 class="product-name"></h1>

      <script>
        const {
          productImage = "https://cdn.example.com/products/default.png",
          productName = "Untitled",
        } = __hyperframes.getVariables();
        const root = document.querySelector('[data-composition-id="product-card"]');
        root.querySelector(".product-img").src = productImage;
        root.querySelector(".product-name").textContent = productName;
      </script>
    </div>
  </body>
</html>
```

<Note>
The runtime probes the DOM after your composition script runs, so a `<video>` or `<audio>` `src` assigned at runtime from a variable is discovered and pre-extracted for the render. No extra wiring required — just set the `src` from your variable.
</Note>

The same pattern covers the three media element types:

- **`<img src>`** — assign from a string variable. Chrome fetches it during capture like any other image; no extra config.
- **`<video src>`** — assign from a string variable, but keep the timing attributes (`data-start`, `data-duration`, `data-track-index`, `data-has-audio`) on the element itself. The probe phase scans `video[data-start]` elements after your script runs and reads the resolved `src` for pre-extraction.
- **`<audio src>`** — same as video. The audio is decoded during capture and mixed into the final output.

Pass assets as URL references your composition resolves at render time; don't inline base64. URL-shaped assets travel cleanly through both the local renderer and the Lambda surface — see [Templates on Lambda](/deploy/templates-on-lambda#working-with-large-variables) for the 256 KiB execution-input cap on distributed renders.

### Parameterizing media color grading

Media color grading can also read exact variable references inside
`data-color-grading`. Use `$name` or `${name}` as the entire value for a field;
the runtime resolves it from the current composition's variables before applying
the shader grading, finishing details, blur/pixelate effects, and optional LUT:

```html compositions/hero.html
<html data-composition-variables='[
  {"id":"gradingPreset","type":"enum","label":"Preset","default":"clean-studio",
   "options":[{"value":"clean-studio","label":"Clean Studio"},{"value":"warm-daylight","label":"Warm Daylight"}]},
  {"id":"gradingIntensity","type":"number","label":"Preset strength","default":0.75,"min":0,"max":1,"step":0.05},
  {"id":"gradingExposure","type":"number","label":"Exposure","default":0,"min":-2,"max":2,"step":0.05},
  {"id":"gradingVibrance","type":"number","label":"Vibrance","default":0.08,"min":-1,"max":1,"step":0.01}
]'>
  <body>
    <div data-composition-id="hero" data-width="1920" data-height="1080">
      <video
        id="hero-video"
        src="assets/hero.mp4"
        data-start="0"
        data-track-index="0"
        muted
        playsinline
        data-color-grading='{
          "preset":"$gradingPreset",
          "intensity":"$gradingIntensity",
          "adjust":{"exposure":"${gradingExposure}","vibrance":"$gradingVibrance"},
          "details":{"vignette":0.15,"vignetteFeather":0.72,"grain":0.08,"grainSize":0.25},
          "effects":{"blur":0.1},
          "colorSpace":"rec709"
        }'
      ></video>
    </div>
  </body>
</html>
```

When the same composition is embedded multiple times, each host's
`data-variable-values` can produce different grading without copying or rewriting
the media element's `data-color-grading` JSON.

### Swapping media: do you need to vary duration too?

A common follow-up: if a variable swaps a `<video>` to a different clip, does `data-duration` need to change too? Usually no. `data-duration` is optional on `<video>` and `<audio>` — leave it off and the renderer ffprobes the source and uses its natural length:

```html compositions/hero.html
<video id="hero" data-start="0" data-track-index="0"></video>
<script>
  document.getElementById("hero").src = __hyperframes.getVariables().heroVideo;
</script>
```

If you need to clamp or pin the clip to a specific length per render — for example, to keep downstream timing stable across clips of different source lengths — expose duration as its own `number` variable and apply it via the same script:

```html compositions/hero.html
<video id="hero" data-start="0" data-track-index="0"></video>
<script>
  const { heroVideo, heroDuration } = __hyperframes.getVariables();
  const el = document.getElementById("hero");
  el.src = heroVideo;
  if (heroDuration !== undefined) {
    el.setAttribute("data-duration", String(heroDuration));
  }
</script>
```

The probe phase reads a **clip's** `data-duration` from the live DOM after your script runs, so an attribute written programmatically onto a clip or media element behaves identically to one baked into the source HTML.

<Warning>
This live-DOM re-read applies to clip and media elements, **not** to the **root composition's own `data-duration`** (its total render length / frame count). The renderer reads the root `data-duration` once at compile time, before your scripts run, exactly like `data-width` / `data-height`. If the root element carries a static `data-duration`, a script (or a variable) that rewrites it afterward is ignored, and the render uses the compile-time value. To make total render length vary per render, author the root `data-duration` directly (one value per output) rather than trying to drive it from a script. See [What can't be a variable](#what-cant-be-a-variable).
</Warning>

## What can't be a variable

A small set of inputs are read once from the source HTML or from the CLI / SDK, with no live-DOM re-read — no script (and therefore no variable) can change them:

| What | Mechanism (not a variable) |
|------|----------------------------|
| Composition dimensions | `data-width` / `data-height` on the composition element, parsed from the source HTML at compile time, not from the live DOM |
| Root composition total duration | `data-duration` on the **root** composition element (the total render length / frame count), parsed from the source HTML at compile time. A static root `data-duration` is locked before scripts run, so neither a script nor a variable can change the render length. (A clip's own `data-duration` is different: it is re-read from the live DOM, as shown above.) |
| Frame rate | `--fps` flag on `hyperframes render`, or `config.fps` in the SDK |
| Output format / codec / quality | `--format` / `--codec` / `--quality` flags, or the SDK equivalents |
| A sibling or parent composition's variables | Variables are per-composition; use [`data-variable-values`](#per-instance-overrides-sub-compositions) on each sub-comp host element to pass overrides |

The deeper rule: variables are runtime values your script applies to the DOM. They can drive anything the renderer reads from the live DOM after that script runs: text, colors, media `src`, even clip `data-duration` as shown above. They can't change inputs the renderer reads once at compile time (composition dimensions, and the root composition's total duration / render length) or that live entirely outside the composition (CLI flags, encoder settings).

## Reading Variables at Runtime

Inside any composition script, call `window.__hyperframes.getVariables()` to get the resolved variable values. The return type is `Partial<Record<string, unknown>>` — use destructuring with defaults matching the declared `default` values:

```html compositions/card.html
<html data-composition-variables='[
  {"id":"title","type":"string","label":"Title","default":"Untitled"},
  {"id":"color","type":"color","label":"Color","default":"#111827"}
]'>
  <body>
    <div data-composition-id="card" data-width="1920" data-height="1080">
      <h1 class="card-title"></h1>

      <style>
        [data-composition-id="card"] { --card-color: #111827; }
        [data-composition-id="card"] .card-title { color: var(--card-color); }
      </style>

      <script>
        const { title = "Untitled", color = "#111827" } = __hyperframes.getVariables();
        const root = document.querySelector('[data-composition-id="card"]');
        root.querySelector(".card-title").textContent = title;
        root.style.setProperty("--card-color", color);
      </script>
    </div>
  </body>
</html>
```

`__hyperframes.getVariables()` is a shorthand for `window.__hyperframes.getVariables()` and works in both top-level and sub-composition scripts. The runtime automatically scopes sub-compositions so each instance sees its own resolved values.

## Declarative Bindings (No Script Required)

For the common cases — replaceable media, dynamic text, and CSS-driven styling — you don't need a script at all. The runtime resolves these bindings once at load, identically in preview and render:

- **`data-var-src="id"`** — sets the element's `src` from the variable value (a URL string, or an image value's `{url}`). The authored `src` stays as the fallback when the variable resolves to nothing:

  ```html
  <img class="clip" data-start="0" data-duration="5"
       data-var-src="heroImage" src="fallback.jpg" />
  ```

  <Note>
  `data-var-src` is only honored on media elements (`img`, `video`, `audio`,
  `source`) and only for safe URL protocols (`http(s):`, `blob:`, relative
  paths, and `data:image/…`). A binding on a script-executing tag such as
  `<iframe>`/`<script>`, or a value using `javascript:`/`data:text/html`, is
  ignored — variable values may be attacker-influenced, so this prevents them
  from becoming a script-injection sink. Scalar values applied as CSS custom
  properties are likewise stripped of declaration-smuggling characters
  (`; { } < >`).
  </Note>

- **`data-var-text="id"`** — sets the element's text content from a scalar variable:

  ```html
  <h1 class="clip" data-start="0" data-duration="5" data-var-text="title">Fallback title</h1>
  ```

- **CSS custom properties** — every scalar variable is applied as `--{id}` on its composition root (font values apply their family name), so plain CSS bindings respond to render/preview overrides:

  ```css
  .card-title { color: var(--accent); font-family: var(--brandFont), sans-serif; }
  ```

Bindings resolve against the element's owning composition, so sub-composition instances see their own per-instance values. The Studio Variables panel counts these bindings as usage. Use `getVariables()` in a script only when you need logic beyond direct substitution (loops, conditionals, derived values).

<Note>
**Content Security Policy.** The preview server injects override values via an
inline `<script>window.__hfVariables=…</script>` tag. If you embed the preview
behind a strict CSP (`script-src 'self'` with no `'unsafe-inline'`), that tag is
blocked and the preview silently falls back to declared defaults — allow it with
a nonce or hash. The declarative-binding runtime itself emits no inline scripts,
and the final rendered output is unaffected.
</Note>

## Per-instance Overrides (Sub-compositions)

When embedding a composition inside another, use `data-variable-values` on the host element to pass a JSON object of override values for that particular instance:

```html index.html
<div
  data-composition-id="card-pro"
  data-composition-src="compositions/card.html"
  data-start="0"
  data-track-index="1"
  data-variable-values='{"title":"Pro","color":"#ff4d4f"}'
></div>
<div
  data-composition-id="card-enterprise"
  data-composition-src="compositions/card.html"
  data-start="card-pro"
  data-track-index="1"
  data-variable-values='{"title":"Enterprise","color":"#22c55e"}'
></div>
```

Both host elements point to the same `card.html` source, but each instance receives different values. The runtime merges the host's `data-variable-values` over the sub-comp's declared defaults on a per-instance basis — the same sub-composition can run with completely different content simultaneously.

## CLI Overrides (Top-level Renders)

Pass variable values at render time with `--variables` or `--variables-file`. These override the declared defaults for the top-level composition:

```bash Terminal
# Inline JSON
npx hyperframes render --variables '{"title":"Q4 Report","color":"#1d4ed8"}' --output q4.mp4

# JSON file
npx hyperframes render --variables-file ./vars.json --output out.mp4

# Fail on undeclared or mistyped variables
npx hyperframes render --variables '{"title":"Q4 Report"}' --strict-variables --output out.mp4
```

`--strict-variables` turns variable warnings into errors. Any variable in `--variables` that is not declared in `data-composition-variables`, or whose value does not match the declared type, causes the render to exit non-zero. Useful in CI pipelines where an undeclared variable key likely indicates a typo or a schema mismatch.

<Note>
  CLI overrides apply only to the top-level composition. Sub-composition variables are controlled by `data-variable-values` on each host element.
</Note>

## Batch Renders

Use `--batch` when the same composition should render once per data row:

```json rows.json
[
  { "name": "Alice", "title": "Q4 Report" },
  { "name": "Bob", "title": "Renewal Plan" }
]
```

```bash Terminal
npx hyperframes render --batch rows.json --output "renders/{name}.mp4" --strict-variables
```

Each row is treated like a `--variables` object and merged over the composition defaults. Output paths support `{key}` placeholders from the row plus `{index}`. Hyperframes validates missing placeholders, output collisions, and `--strict-variables` issues before the first row starts rendering, then writes `manifest.json` next to the outputs with one status row per render.

For small compositions, `--batch-concurrency 2` can run rows in parallel. The default is `1` because each individual render already parallelizes across render workers.

## Layering and Precedence

Variable values are resolved by merging three sources, lowest to highest precedence:

| Source | Precedence | Where declared |
|--------|-----------|---------------|
| Declared defaults | Lowest | `data-composition-variables` on the declaration root (`<html>`, or the composition root div for template/fragment comps) |
| Per-instance host overrides | Middle | `data-variable-values` on the sub-comp host element |
| CLI `--variables` flag | Highest | `hyperframes render --variables '{...}'` |

A missing key at any layer falls through to the next lower layer. If no layer provides a value, the declared `default` is used.

## Validation

The linter checks variable declarations statically:

```bash Terminal
npx hyperframes lint
```

It catches malformed JSON, missing required fields (`id`, `type`, `label`, `default`), and type mismatches between `type` and the `default` value. Fix lint errors before rendering — they indicate the runtime will be unable to resolve variables correctly.

At render time, the CLI validates `--variables` against the schema and reports issues as warnings (or errors with `--strict-variables`):

- **undeclared** — a key in `--variables` has no matching `id` in `data-composition-variables`
- **type-mismatch** — the value's JavaScript type does not match the declared `type` (e.g. a string where a number is expected)
- **enum-out-of-range** — an enum value is not in the declared `options` list

## Inspecting Variables Programmatically

If you are building tooling on top of `@hyperframes/core`, the variable declarations are readable without rendering:

```typescript
import { extractCompositionMetadata } from "@hyperframes/core";
import { readFileSync } from "node:fs";

const html = readFileSync("compositions/card.html", "utf8");
const { variables } = extractCompositionMetadata(html);
// variables is CompositionVariable[]
```

This is the same API the Studio Variables panel uses to build its editor for each composition.

## Variables in Studio

The Studio's **Variables** tab (right inspector panel) is a full UI over this
system:

- **Declare and edit** — add, edit, and remove declarations without touching the
  HTML by hand; edits persist into `data-composition-variables` with undo support.
- **Preview with values** — type-appropriate inputs write ephemeral overrides that
  are injected into the preview as `window.__hfVariables`, exactly like render-time
  injection, so what you preview is what `--variables` renders. A header pill shows
  whether you're previewing defaults or custom values.
- **Render with values** — renders started from the Renders tab carry the active
  preview overrides.
- **Handoff** — copy the effective values as JSON or as a ready-to-run
  `hyperframes render --variables` command.
- **Usage** — declarations no script reads are badged `unused`; ids read by scripts
  but missing from the schema get a one-click Declare action.

## Next Steps

<CardGroup cols={2}>
  <Card title="Data Attributes" icon="code" href="/concepts/data-attributes">
    Full reference for data-composition-variables and data-variable-values attributes
  </Card>
  <Card title="Compositions" icon="layer-group" href="/concepts/compositions">
    How nested compositions use variables for reuse
  </Card>
  <Card title="Rendering" icon="play" href="/guides/rendering">
    CLI flags for passing variables at render time
  </Card>
  <Card title="CLI Reference" icon="terminal" href="/packages/cli">
    All CLI commands and flags
  </Card>
</CardGroup>
