Template format
The files a Kinetra template is made of, and the rules the output follows when it draws one.
This page is for people who write or edit templates by hand, in Kinetra’s code editor or in their own editor. You don’t need it to use the built-in templates or AI templates.
Kinetra watches the project folder: save a file in another editor and the change shows up in Kinetra and on air. If an edit made outside Kinetra isn’t valid, the last good version stays on air.
A template is one folder in the project’s templates/ folder:
<project>/templates/<id>/ template.json the manifest: name, fields, variables, looks variants/ <variantId>/ template.html this look's markup; its first element is the root template.css this look's styles <variantId>/ …<id>is the template’s id: letters, digits,_and-, 1–64 characters. Kinetra makes 10-character ids. Folders with other names are ignored.- Each look (variant) listed in
template.jsonhas a folder invariants/. A missingtemplate.htmlortemplate.csscounts as empty. - Files are UTF-8.
- Images and video don’t live in the template. They live in the project’s
assets/folder and are used as/assets/<file>in field values. - Templates from older versions of Kinetra (HTML and CSS directly in the template folder) are converted to a single look called “Default” when you open the project. A backup is made first.
- A
template.jsonfrom a newer version of Kinetra is never overwritten. The template won’t open until you update Kinetra.
The manifest: template.json
Section titled “The manifest: template.json”{ "schemaVersion": 2, "name": "Lower third", "description": "A name strap.", "preset": null, "fields": [], "variables": [], "autoOutMs": null, "design": { "w": 960, "h": 200, "x": 115, "y": 783, "sizing": "fit" }, "defaultVariant": "default", "variants": [], "updatedAt": "2026-10-10T12:00:00Z"}| Key | Type | Rules |
|---|---|---|
schemaVersion |
number | 2. |
name |
string | 1–100 characters after trimming. |
description |
string | Up to 1000 characters. Optional. |
preset |
{id, version} or null |
Set when the template was copied from a built-in template, so Kinetra can offer newer looks. Use null for your own templates. |
fields |
array | The content an operator fills in. Up to 64. See Fields. |
variables |
array | Styling knobs exposed as CSS custom properties. Up to 32. See Variables. |
autoOutMs |
number or null |
Take the graphic off air this many milliseconds after it goes on, 500–600000. null keeps it on air until it is cleared. |
design |
object | Optional design box. Leave it out for a full-screen template. See Design box. |
defaultVariant |
string | The look a layer shows unless it picks another. Must be one of variants. |
variants |
array | The looks, 1–12. See Looks (variants). |
updatedAt |
string | Written by Kinetra on every save. |
Keys of fields, variables and list columns start with a lowercase letter, followed by up to 31 lowercase letters, digits, _ or -. A field and a variable can’t share a key.
Labels are what operators see. They are 1–100 characters after trimming.
Design box
Section titled “Design box”The size the graphic is designed at, and where a new layer is placed.
| Key | Rules |
|---|---|
w, h |
Whole numbers, 16–8192. |
x, y |
Optional. A new layer’s top-left corner on a 1920×1080 screen, scaled to the screen’s size. Left out: centered. |
sizing |
"fit" or "reflow": what new layers from this template use. Default "reflow". |
fit(Scale to fit): the template is laid out at exactlyw×hpixels, then scaled evenly (never stretched) and centered in the layer’s box. Use it for designed graphics such as lower thirds, bugs and titles.reflow: the template fills the layer’s box and lays itself out. Use it for grids, tickers, schedules and sponsor walls, with container units and container queries.- Without
design, a new layer fills the screen, andfituses the screen’s size. - Make the design box hug the graphic, so resizing the layer resizes what you see.
Fields
Section titled “Fields”Fields are the content: names, titles, images, rows, timers. They are declared once in template.json, shared by every look, and placed in the HTML with {{key}}.
{ "key": "name", "label": "Name", "type": "text", "default": "" }| Key | Rules |
|---|---|
key |
See the key rules above. |
label |
1–100 characters. |
type |
One of the types below. |
default |
A string: the value a new layer starts with. |
columns |
list only: 1–16 {key, label, kind?}. kind is "text" (the default) or "image". |
format |
clock only: the display format. |
Field types
Section titled “Field types”All values are strings. Every type except html is inserted as plain text.
| Type | Value | Notes |
|---|---|---|
text |
Plain text. | One line in the operator’s form. |
multiline |
Plain text with line breaks. | Line breaks only show if your CSS sets white-space: pre-line or similar. |
number |
A number as text. | Shown as written. |
color |
A color. | Usually bound into a style attribute. |
image |
A URL, usually /assets/<file>. |
Bind it into src. Only http(s), relative and data:image/… URLs are accepted. |
video |
A URL, usually /assets/<file>. |
Bind it into a <video src>. Same URL rules. |
html |
Markup. | Inserted as markup, with scripts, event handlers and unsafe URLs removed. Only in text position; in an attribute it is plain text. |
list |
A JSON array of rows. | Repeated with data-each. See Lists. |
clock |
No value. | Shows the time of day. |
timer |
A timer state (JSON). | Counts up or down. Operators start, pause and reset it from Control. |
List values are a JSON array of objects, one key per column, every column present:
[{"text":"Doors open 18:00"},{"text":"Bar closes 01:00"}]At most 100 rows, 4 KB per cell and 64 KB in total. Image cells hold /assets/<file>. The default must be a valid list ("[]" for none).
When a list is bound to a data source, Kinetra can add row marks: _status (previous, now, next, delayed, upcoming or cancelled, on schedules), _current (true on a slideshow’s current row) and _duration (seconds). They show up as data-status, data-current and data-duration on the row element.
Clock format uses HH (00–23), mm, ss, :, . and spaces, up to 32 characters. Empty means HH:mm:ss.
Timer values look like this:
{"run": false, "at": 0, "base": 0, "dir": "down", "end": "stop"}dir is "up" or "down". A countdown stops at zero; with "end": "hide" the graphic is taken off air at zero. Timers show as m:ss under an hour and h:mm:ss from an hour. The default is "" (a stopped count-up at 0:00) or a timer state; a default never starts running by itself.
Each layer keeps its own field values. A field with no value on a layer uses its default. A field can also be bound to a data source; that is set on the layer, not in the template.
Variables
Section titled “Variables”Variables are styling knobs (colors, fonts, sizes, choices) that an operator can change per layer without touching code. Each variable becomes the CSS custom property --<key>, so var(--<key>) works anywhere in template.css, including :host.
{ "key": "accent", "label": "Accent", "type": "color", "default": "#33d6ff" }{ "key": "font-heading", "label": "Heading font", "type": "font", "default": "Space Grotesk" }{ "key": "radius", "label": "Corner radius", "type": "range", "min": 0, "max": 24, "step": 1, "unit": "px", "default": "2" }{ "key": "align", "label": "Alignment", "type": "select", "options": [{ "value": "left", "label": "Left" }, { "value": "center", "label": "Center" }], "default": "left" }Every value, including the default, is checked and rewritten by Kinetra before it reaches CSS.
| Type | Extra keys | Valid values | Value of --<key> |
|---|---|---|---|
color |
none | #rgb, #rrggbb, #rrggbbaa or transparent |
The value in lowercase. |
font |
none | One family from the font list below | The family with fallbacks, for example "Barlow", system-ui, sans-serif. |
range |
min, max, step, unit (up to 8 lowercase letters or %, "" for none) |
A plain number from min to max |
The number plus the unit, for example 12px. |
select |
options: 1–64 {value, label} |
One of the option values | The option value. |
A layer’s value comes from, in order: the layer’s own setting, the project’s brand, the look’s vars, then the variable’s default. The brand fills only these keys: accent, secondary, text, plate, font-heading and font-body. It also fills an empty logo image field with the brand logo. Use these keys if a template should follow the project’s brand (see Templates and styles).
Templates can only use these families. Kinetra serves them itself, so every OBS or vMix computer shows the same font:
Barlow, Barlow Condensed, Oswald, Montserrat, IBM Plex Sans, IBM Plex Sans Condensed, Source Serif 4, Space Grotesk, Manrope, Archivo, Playfair Display, Jost, Instrument Sans, Plus Jakarta Sans, Outfit, Roboto Mono, IBM Plex Mono.
Weights 400, 600 and 700 are included for every family; some include more. Only some families have italics. Inter is not available: a template that names Inter gets IBM Plex Sans.
Looks (variants)
Section titled “Looks (variants)”A look is one design of the template: its own HTML and CSS and its own variable values. All looks share the template’s fields and variables. Typical looks: Default, Compact, Sponsor.
"defaultVariant": "default","variants": [ { "id": "default", "name": "Default", "vars": {} }, { "id": "compact", "name": "Compact", "vars": { "radius": "0", "accent": "#e63946" } }]| Key | Rules |
|---|---|
id |
Starts with a lowercase letter, then up to 31 lowercase letters, digits or - (no _, because it is also the folder name). Unique. |
name |
1–40 characters, unique ignoring case. |
vars |
This look’s values for declared variables. Missing keys use the variable’s default. |
- A template has 1–12 looks. The last one can’t be deleted.
- A look doesn’t have to use every field. Values stay on the layer, so switching to a look that ignores a field and back keeps the value.
- A layer that names a look that no longer exists shows the default look.
Structure
Section titled “Structure”- The first element of
template.htmlis the root. Kinetra setsdata-state="in"on it when the graphic goes on air anddata-state="out"when it goes off. Write exactly one top-level element. - Each layer is drawn in its own shadow root: the template’s CSS can’t affect anything else, and nothing else affects it.
template.htmlcan be up to 256 KB.
Placeholders: {{key}}
Section titled “Placeholders: {{key}}”{{key}} (or {{ key }}) is replaced by the field’s value, in text and in attribute values:
<div class="name">{{name}}</div><div class="role">{{title}} · {{company}}</div><img class="photo" src="{{photo}}" alt=""><div class="chip" data-status="{{status}}"></div>- Values are inserted as text, except
htmlfields. - A placeholder that names no field shows nothing.
clockandtimerplaceholders show the time and update once a second while on air.- Changing a value updates only that text or attribute. Running animations don’t restart.
Attribute rules
Section titled “Attribute rules”| Attribute | Rule |
|---|---|
on* (event handlers), srcdoc |
Placeholders are never filled in. |
src, href, srcset, poster, xlink:href, action, formaction |
The value must be http(s), relative or data:image/…. Anything else becomes empty. |
style |
Each value must be only letters, digits, spaces and # _ . % + -, up to 64 characters: colors, numbers, lengths and plain words. No url(), brackets, colons, semicolons or quotes. Anything else becomes empty. Example: style="--team: {{color}}". |
| everything else | The value as given. |
Lists: data-each
Section titled “Lists: data-each”An element with data-each="<listField>" is repeated once for each row of that list field.
<ul class="rows"> <li class="row" data-each="items" data-limit="8"> <span class="n">{{#}}</span> <span class="t">{{.title}}</span> <img src="{{.logo}}" alt=""> <span class="when">{{start}}</span> </li></ul>| Inside a row | Meaning |
|---|---|
{{.col}} |
This row’s value in column col. |
{{#}} |
The row number, starting at 1. |
{{key}} |
An ordinary field, the same in every row. |
data-limit="<n>"shows at mostnrows.- Each row gets
style="--i: <index>"(starting at 0) for staggered animations, anddata-row="in"when it is added. A row removed while on air getsdata-row="out"and disappears when its animation ends. - Row marks become attributes on the row:
data-status="now",data-current="true",data-duration="8". - The row’s parent gets
data-count="<n>"and--n: <n>, anddata-emptywhen there are no rows. data-eachdoesn’t work on the root element or inside anotherdata-each. Two separate row templates for the same list (for example a ticker’s repeated run) are fine.
Helper attributes
Section titled “Helper attributes”| Attribute | Set by | Meaning |
|---|---|---|
data-state="in" / "out" |
Kinetra, on the root | On air / going off air. |
data-empty |
Kinetra | On an element whose filled-in text and URLs are all empty, and on a list’s parent with no rows. Use it to hide missing parts: .photo[data-empty] { display: none }. |
data-fit |
You | Shrinks the text until it fits the element’s width in at most data-fit-lines lines (default 1), down to data-fit-min times the CSS size (default 0.6). |
data-row, data-count, --i, --n |
Kinetra | Lists. |
Don’t set data-state or data-row yourself.
template.css can be up to 256 KB.
You can use:
| What | Where |
|---|---|
--<key> for every variable |
Set on the host and inherited everywhere. |
--auto-out-ms (for example 5000ms) |
On the host when the template has autoOutMs. Handy for a progress bar that drains while on air. |
:host |
The layer itself, placed at the layer’s box. Overflow is visible, so out-animations can leave the box. |
| Container queries | With fit, put container-type: size on your root to query the design box. With reflow, the root fills the layer box. |
| The fonts listed above | Name them in font-family or font, directly or through a font variable. Kinetra loads them before the graphic plays in (waiting at most 1.5 seconds). |
Anything else that the Chromium-based browser in OBS and vMix supports works: layout, color-mix(), container units, @keyframes, transitions, :has() and custom properties.
Don’t use:
| Don’t | Why |
|---|---|
url(…) and @import |
Templates load nothing from outside. Images come in through image fields. |
@font-face |
It doesn’t work inside a shadow root. Use the fonts above. |
@media (prefers-reduced-motion) |
Windows’ “Animation effects: off” setting turns this on in OBS and vMix and would freeze your graphics. |
| Other fonts, or Inter | They aren’t included, so the output would show a fallback font. |
| A weight or italic the family doesn’t include | The browser fakes it. |
Tips: animate only transform and opacity. Keep the important part of a fit graphic inside its design box. Hide missing parts with data-empty or :empty.
Animation
Section titled “Animation”- In. When the graphic goes on air, the root gets
data-state="in". Put the in-animation on.root[data-state="in"], withanimation-fill-mode: bothso it starts hidden. - Out. When it goes off air, the root gets
data-state="out". Put the out-animation on.root[data-state="out"], withforwardsso it stays hidden. The layer is removed when its animations finish, after at most 3 seconds. - No out-animation means the graphic disappears at once.
- Updates on air never replay the in-animation. Endless animations, such as a ticker’s scroll, keep running.
- Switching looks on air crossfades in about 300 ms, or plays the old look’s out and the new look’s in.
- PANIC removes the graphic at once, with no out-animation.
- Preview shows graphics settled, without in and out animations.
Templates need no JavaScript: clocks, timers and look changes are handled by Kinetra.
Never allowed
Section titled “Never allowed”Kinetra checks every template when it is saved (and every AI result) and refuses it with “Templates can’t contain …” if it has:
- JavaScript:
<script>elements,on…event handler attributes, andjavascript:,vbscript:ordata:text/htmlURLs. - Embedded pages and forms:
<iframe>,<frame>,<frameset>,<object>,<embed>,<applet>,<portal>,<form>, and thesrcdocandformactionattributes. - Page-level tags:
<link>,<meta>and<base>. - Anything from outside: an attribute that starts with
http://,https://or//, and in CSSurl(),@importand@font-face. Data from the internet is fetched by Kinetra and arrives as field values; images are imported into the project. - Old CSS tricks:
expression(,-moz-bindingandbehavior:.
The rules in CSS under “Don’t use” (reduced motion, other fonts, missing weights) aren’t checked on save. Breaking them doesn’t stop the save, but the graphic won’t look right on air.
Don’t put anything private in a template: templates are copied into exports and backups.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Template id | Letters, digits, _, -; 1–64 characters |
| Name / description | 1–100 / up to 1000 characters |
| Field, variable and column keys | Lowercase letter first, then up to 31 of a–z 0–9 _ -; fields and variables can’t share a key |
| Labels | 1–100 characters |
| Fields / variables / looks | Up to 64 / up to 32 / 1–12 |
| Look id / name | Lowercase letter first, then up to 31 of a–z 0–9 - / 1–40 characters |
template.html, template.css |
Up to 256 KB each, per look |
| Select options | 1–64 |
| List | 1–16 columns, up to 100 rows, 4 KB per cell, 64 KB in total |
| Clock format | HH, mm, ss, :, . and spaces; up to 32 characters |
| Timer | Up to 1000 hours |
autoOutMs |
500–600000 |
| Design box | 16–8192 pixels per side |
| Out-animation | Removed after at most 3 seconds |
If template.json has an error, Kinetra won’t save it and the editor says what to fix.
Example
Section titled “Example”A lower third with a name, a role, an optional photo, topic tags, two looks and a progress bar. It goes off air after 8 seconds and scales to fit a 960×200 box at the bottom left.
templates/k3m9x2p7qa/template.json:
{ "schemaVersion": 2, "name": "Speaker strap", "description": "Name, role, photo and topic tags.", "preset": null, "fields": [ { "key": "name", "label": "Name", "type": "text", "default": "" }, { "key": "role", "label": "Role", "type": "text", "default": "" }, { "key": "photo", "label": "Photo", "type": "image", "default": "" }, { "key": "tags", "label": "Topics", "type": "list", "columns": [{ "key": "tag", "label": "Topic" }], "default": "[]" } ], "variables": [ { "key": "accent", "label": "Accent", "type": "color", "default": "#33d6ff" }, { "key": "plate", "label": "Plate", "type": "color", "default": "#0c0d11d9" }, { "key": "text", "label": "Text", "type": "color", "default": "#f5f5f2" }, { "key": "font-heading", "label": "Heading font", "type": "font", "default": "Barlow" }, { "key": "radius", "label": "Corner radius", "type": "range", "min": 0, "max": 24, "step": 1, "unit": "px", "default": "6" } ], "autoOutMs": 8000, "design": { "w": 960, "h": 200, "x": 115, "y": 783, "sizing": "fit" }, "defaultVariant": "default", "variants": [ { "id": "default", "name": "Default", "vars": {} }, { "id": "light", "name": "Light", "vars": { "plate": "#ffffff", "text": "#16202e", "accent": "#2563eb", "radius": "0" } } ], "updatedAt": "2026-10-10T12:00:00Z"}variants/default/template.html:
<div class="strap"> <div class="plate"> <img class="photo" src="{{photo}}" alt=""> <div class="text"> <div class="name" data-fit data-fit-min="0.6">{{name}}</div> <div class="role" data-fit data-fit-lines="2">{{role}}</div> <ul class="tags"> <li class="tag" data-each="tags" data-limit="4">{{.tag}}</li> </ul> </div> <span class="timer-bar" aria-hidden="true"></span> </div></div>variants/default/template.css:
:host { --ease: cubic-bezier(0.2, 0, 0, 1); --ease-in: cubic-bezier(0.4, 0, 1, 1);}
.strap { position: absolute; inset: 0; display: flex; align-items: flex-end; container-type: size;}
.plate { position: relative; display: flex; gap: 24px; align-items: center; max-width: 100%; padding: 20px 32px 20px 20px; background: var(--plate); color: var(--text); border-left: 6px solid var(--accent); border-radius: var(--radius); overflow: hidden;}
.photo { width: 120px; height: 120px; object-fit: cover; border-radius: var(--radius);}.photo[data-empty],.role[data-empty],.tags[data-empty] { display: none;}
.text { min-width: 0; }
.name { font: 700 56px/1.1 var(--font-heading); white-space: nowrap;}
.role { margin-top: 4px; font: 400 28px/1.25 var(--font-heading); opacity: 0.85;}
.tags { display: flex; gap: 8px; margin: 10px 0 0; padding: 0; list-style: none;}
.tag { padding: 2px 10px; border: 1px solid var(--accent); border-radius: 999px; font: 600 18px/1.4 var(--font-heading);}.tag[data-row="in"] { animation: fade-up 200ms var(--ease) both; animation-delay: calc(var(--i) * 60ms + 200ms);}.tag[data-row="out"] { animation: fade-out 160ms var(--ease-in) forwards;}
/* Drains over the time on air (autoOutMs). */.timer-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: var(--accent); transform-origin: 0 50%;}.strap[data-state="in"] .timer-bar { animation: drain var(--auto-out-ms, 0ms) linear both;}
.strap[data-state="in"] .plate { animation: plate-in 320ms var(--ease) both;}.strap[data-state="out"] .plate { animation: plate-out 220ms var(--ease-in) forwards;}
@keyframes plate-in { from { opacity: 0; transform: translateX(-24px); }}@keyframes plate-out { to { opacity: 0; transform: translateX(-24px); }}@keyframes fade-up { from { opacity: 0; transform: translateY(8px); }}@keyframes fade-out { to { opacity: 0; }}@keyframes drain { to { transform: scaleX(0); }}The light look differs only in its vars, so variants/light/ can hold a copy of the same two files.