Field types reference
{{ field('name', 'type') }} renders an editable content block. Available
types are listed in cms-core/src/Twig/FieldExtension.php::ALLOWED_TYPES
and validated by cms-core/src/Field/FieldSanitizer.php.
For each type below: payload shape (what's stored on Page.fields[name]),
the partial markup (what renders), and the styling hooks themes can use.
Choosing a type
Match the type to what the editor needs to do, not to what the visual result looks like. Wrong type = wrong editor UI, sometimes also = no inline editing at all.
| You want | Type | Wrapper rendered | Inline edit? |
|---|---|---|---|
| Long-form rich body (paragraphs, H2/H3, lists, links) | html |
<div class="field-html"> |
Yes (rich) |
Editable text inside a tag the theme controls (<h1 class="…">, <span>, etc.) |
text |
None | Yes (single line) |
| One image with layout knobs (alt, width, alignment, fit) | image |
<figure class="field-image"> |
Cog dialog only |
| List of images or video (grid, carousel, lightbox, video slider) | gallery |
<div class="field-gallery"> |
Cog dialog only |
| Hero rotating banner with overlay text + CTAs | slideshow |
<div class="cms-slideshow"> |
Cog dialog only |
| Two images compared with a draggable divider (before/after) | compare |
<figure class="cms-compare"> |
Cog dialog only |
| One uploaded video (inline, or a header background loop) | video |
<div class="field-video"> |
Cog dialog only |
| Opaque third-party HTML (map, YouTube/Vimeo embed) | embed |
<div class="field-embed"> |
Cog dialog only |
| Visual separator | divider |
<hr class="field-divider"> |
n/a |
videois for a file in the media library;embedis for someone else's player. Useembedonly for YouTube, Vimeo or a map, where the markup isn't yours. A video you uploaded goes in avideofield — you get the poster, the smaller renditions and no third-party branding.
A theme can also render a video without a field, by passing a source to
cms_video()directly. Use the field when an editor should choose the video; use the helper when the theme owns it.
The most common mistake is reaching past
field()to readpage.getFields()['x'].content|rawdirectly because the theme wants control of the outer tag. That works on the public site but breaks the editor — the inline editor binds to the wrapper thatfield()emits in editor mode; without it, the field is invisible to the editor. Usetextinstead — see its section below.
How editors discover fields
Two surfaces work together to make sure editors can find and fill everything the template renders:
-
Side panel — the admin's inline page editor shows a list of every field the template calls
field()for, with a filled/empty dot. Click a row → the iframe scrolls to that field and focuses it. The list is derived from a static scan of the page template (seecms-core/src/Website/PageSchemaExtractor.php), so a designer who adds a new{{ field('foo', 'text') }}to a template seesfooappear in the side panel the moment the page reloads — no schema file to keep in sync. -
Inline placeholders — empty fields aren't 0-height invisible boxes.
image/gallery/slideshow/embedshow a labelled "Click ⚙ to choose an image" zone;textshows the field name in italics as a placeholder that disappears the moment the editor types. Both are CSS-only and don't pollute the saved content.
Dynamic field calls inside loops (field(slot ~ '_value', 'html')) can
only be expanded by reading the page's stored fields, since the static
scan doesn't know what slot will be. The side panel merges template-
declared names with stored names so loop fields show up too — see
PageSchemaExtractor::merge.
html
Rich text with TipTap-allowlisted inline + block marks. The HTML is sanitised on save.
No element keeps a class or a style, with three exceptions for HTML
written outside the editor (the docs.ceems.nl handbook is Markdown turned
into this field):
- headings keep their
id, so a/page#sectionlink lands; <code>keeps alanguage-*class (<pre><code class="language-twig">) and drops every other class, so thehighlightmodule can colour the block;<ol>keepsstart, so a numbered list that resumes after a code block keeps counting.
Tables, <pre>, <div> and <span> survive too, but the editor has no
table support: opening such a field in the admin and saving it loses them.
Stored payload
{ "type": "html", "content": "<p>Hello <strong>world</strong>.</p>" }
Renders
<div class="field-html">…sanitised HTML…</div>
Editor: contentEditable in place. The full-mode TipTap menu
offers bold, italic, underline, strike, paragraph, H2 + H3, bullet
list, ordered list, blockquote, link.
Realistic example
{# In the template: #}
<article class="prose mx-auto py-12">
{{ field('intro', 'html') }}
</article>
What an editor will type — multi-paragraph body with a heading, a list, and a link:
{
"type": "html",
"content": "<p>PMU staat voor <strong>Permanente Make-Up</strong>. Tijdens een behandeling brengen we natuurlijke pigmenten aan in de huid.</p><h2>Wat we bieden</h2><ul><li>Wenkbrauwen — powder, combi, ombré, nano</li><li>Lippen — pigmentatie en contouring</li></ul><p>Lees meer over <a href=\"/pmu\">de procedure</a>.</p>"
}
The same template, on a fresh page where the field is empty, renders the
empty <div class="field-html"> so the editor still has a click target.
Default value. Pass a third argument to seed fallback markup when the field has no content (key absent or stored-but-empty):
{{ field('contact_email', 'html', '<a href="mailto:x@y.nl">x@y.nl</a>') }}
This replaces the old field(...)|default('x') idiom, which silently
broke in the editor. See
twig-helpers.md → field.
text
Editable inline-rich content with no wrapper element. The theme
owns the surrounding tag — typically the page's <h1>, a <span>
caption, a custom <dt> label, etc.
This is the right primitive when you'd otherwise be tempted to read
page.getFields()['x'].content|raw by hand to keep control of the
outer markup. The bare-content render makes the result clean, and
field() still emits the editor's <div data-cms-field-name="…">
wrapper in editor mode so the SPA finds and edits the field.
Stored payload
{ "type": "text", "content": "Pure <em>fragrance</em>" }
Content sanitisation is the same surface as html — TipTap's full
allowlist, so inline <em>, <strong>, links, etc. survive. There's
no wrapper-shape concern at the data level; only the partial differs.
Renders
{{- field.content|default('')|raw -}}
That is — the partial emits content only, no element. In editor
mode FieldExtension wraps it in
<div class="cms-field" data-cms-field-name="…" data-cms-field-type="text">…</div>
so the editor binds; on the public site there is no wrapper at all.
Use it like this — keeping the theme's outer tag and classes:
{# Hero <h1>: theme picks the tag and the styling, editor still
types the words and any inline <em> accents. #}
<h1 class="font-display font-light text-[clamp(3rem,7vw,6.5rem)] leading-[0.96]
[&_em]:text-brass-deep [&_em]:italic">
{{ field('hero_title', 'text') }}
</h1>
{# Sidebar label inside a span — same pattern. #}
<span class="block font-sans text-[11px] uppercase tracking-[0.32em] text-brass-deep">
{{ field('side_title', 'text') }}
</span>
{# Definition-list label inside a <dt>. #}
<dt class="font-sans text-[10.5px] uppercase tracking-[0.32em] text-brass-deep">
{{ field('contact_label', 'text') }}
</dt>
Editor: contentEditable on the wrapper <div> itself (no inner
partial to bind to). Inline TipTap surface; line breaks aren't
stripped, so an editor pressing Enter inside an <h1> will insert a
<br>. If your design assumes single-line use, the surrounding tag's
CSS (e.g. white-space: nowrap) is your guard.
Default value. Pass a third argument for fallback copy shown until an editor sets the field — handy for meta rows, labels, and statement headlines that should never render blank:
<span class="v">{{ field('hero_meta_location', 'text', 'Zaandam, NL') }}</span>
Prefer this over field(...)|default('x'), which doesn't survive editor
mode. See
twig-helpers.md → field.
image
Single image with layout knobs.
Stored payload
{
"type": "image",
"src": "/media/uploads/foo.jpg",
"alt": "Description for screen readers",
"caption": "Optional visible caption",
"width": "small | medium | large | full",
"align": "left | center | right",
"radius": "none | small | large | circle",
"fit": "auto | cover"
}
After server-side enrichment (enrichImage) the payload also carries
intrinsicWidth, intrinsicHeight (from the Media row, for CLS-safe
sizing), srcsetWidths (the resize ladder 320/480/640/960/1280/1920,
cut off at the original's width) and loading: "lazy" (default). An SVG
or a GIF gets no srcsetWidths and renders with its bare src: an
animated GIF is served as its original whatever the params. A WebP or an
AVIF keeps its ladder, since most of them are stills; an animated one is
served as its original at every width, so it still moves.
Renders
<figure class="field-image"
data-width="medium" data-align="center" data-radius="none" data-fit="auto">
<img src="/media/uploads/foo.jpg?w=1600"
srcset="/media/uploads/foo.jpg?w=320 320w, …, /media/uploads/foo.jpg?w=1600 1600w"
sizes="(max-width: 600px) 100vw, 600px"
alt="…" width="…" height="…" loading="lazy" decoding="async">
<figcaption>Optional visible caption</figcaption>
</figure>
The browser picks the smallest candidate that covers the rendered size, so
a 2000px upload in a 600px column no longer downloads at full size. The
default sizes follows the data-width presets in blocks.css (small
280px, medium 600px, large 880px, full 100vw). An SVG has no stored
size and is served verbatim, so it keeps a bare src.
Template options: field() takes an options hash, passed by name.
For image fields it reads loading (eager|lazy), fetchpriority
(high|low|auto) and sizes; anything else is ignored.
{# The page header is the LCP image: load it now, ahead of everything else. #}
{{ field('header_foto', 'image', options: {loading: 'eager', fetchpriority: 'high'}) }}
{# A theme that changes the width presets, or puts the field in a grid, says how wide it really is. #}
{{ field('team_photo', 'image', options: {sizes: '(min-width: 1024px) 33vw, 100vw'}) }}
Use eager + high for at most one image per page, the one above the
fold. Everything else should stay lazy.
Theme hooks: [data-width], [data-align], [data-radius],
[data-fit] give CSS handles. Defaults live in
cms-public/public/assets/blocks.css.
Editor: media picker for src; alt + caption text inputs; selects for
the four enums. No inline edit — open via cog button.
Realistic example
{# Template — that's literally all you need: #}
<section class="container py-12">
{{ field('about_portrait', 'image') }}
</section>
Stored payload after an editor uploads a portrait, sets a width, and adds an alt:
{
"type": "image",
"src": "/media/uploads/portrait-melanie.jpeg",
"alt": "Portret van Melanie, eigenaresse van de studio",
"caption": "",
"width": "medium",
"align": "center",
"radius": "large",
"fit": "cover",
"intrinsicWidth": 1600,
"intrinsicHeight": 2000,
"loading": "lazy"
}
The four enums map to CSS via the data-* attributes — see
cms-public/public/assets/blocks.css for the default sizes that
data-width="medium" and friends resolve to. A theme can override:
.field-image[data-width="medium"] { max-width: 32rem; }
.field-image[data-radius="large"] { border-radius: 1.5rem; }
video
One uploaded video, with playback and layout knobs.
{{ field('hero_video', 'video') }}
Stored payload
{
"type": "video",
"src": "/media/80/80fb58aa4cb63022.mp4",
"alt": "Onze werkplaats",
"caption": "",
"autoplay": true,
"loop": true,
"controls": false,
"mobile": "poster",
"expandable": true,
"width": "full",
"align": "center",
"radius": "large",
"radius_tl": "inherit",
"radius_tr": "none",
"radius_br": "none",
"radius_bl": "inherit"
}
Rendered markup
<div class="field-video" data-width="full" data-align="center" data-radius="none">
<div class="cms-video-frame cms-video-frame--cover">…</div>
</div>
The player is cms_video() — the
field only adds the [data-width]/[data-align]/[data-radius] wrapper, the
same three knobs image exposes, so a theme already styling one handles both.
Everything about playback (rendition choice, poster, forced mute under
autoplay, the mobile still) lives in the helper.
Template options: the same loading, fetchpriority and sizes an
image field takes, applied to the mobile still. A header video's still is
the LCP element on a phone:
{{ field('hero_video', 'video', options: {fetchpriority: 'high'}) }}
Editor: media picker restricted to video; toggles for play-automatically, loop and controls; a choice of what phones get; alt, caption; width, alignment, corner radius (individually if needed), and whether visitors can enlarge it.
Dependent defaults. Ticking play automatically means a background loop,
so loop defaults on and controls off. Leaving it off means a video the
visitor starts, so controls defaults on and loop off. Both stay explicitly
overridable — they are defaults, not rules.
Autoplay is always silent. Browsers refuse to autoplay a video with sound; the renderer forces muted regardless of the payload. The editor toggle says "play automatically" rather than "mute" because muting is a consequence, not a choice.
Corners can be set individually. radius sets all four; each
radius_tl/tr/br/bl either follows it ("inherit") or states its own
step, so "top-left round, top-right square" is expressible. The partial
composes them into one border-radius shorthand built from named CSS steps
(--cms-video-radius-none|small|large), so the actual scale stays the theme's
and stored content carries intent rather than pixel values.
Enlarging is not fullscreen. "expandable": true (the default for a video
that doesn't autoplay) adds a control that grows the video over the page;
clicking beside it or pressing Escape restores it. The browser's fullscreen is
deliberately avoided — it hides the site and on iOS hands the clip to the
system player.
On phones, "mobile": "poster" shows the generated poster frame as a still
instead of the video — the largest file on the smallest screen over the worst
connection is the trade worth avoiding. The video is then not downloaded at all
at that width. A different photo is a theme-level call: cms_video()'s
mobile option also takes a /media/... path.
gallery
A list of images — and video. An item is whatever the editor picked; the
renderer resolves each media row and emits an <img> or a player accordingly,
so a gallery can be all images, all video, or mixed.
That is also why there is no separate video slider:
cms_gallery(name, 'carousel') becomes one as soon as the field holds video.
Video slides behave differently from a standalone video field in two ways
that matter on a page with several of them:
- Nothing but the poster loads until a visitor plays one. A slider is the one place where several videos share a page, and preloading metadata for all of them is how a single component eats the page's whole media budget.
- A slide that scrolls out of view is paused, so swiping on never leaves audio playing behind you.
They are enlargeable by default, since a slide is small by nature.
Stored payload
{
"type": "gallery",
"items": [{ "src": "/media/...", "alt": "…" }, …],
"columns": "auto | 2 | 3 | 4",
"width": "medium | large | full"
}
After enrichment items carry loading: "eager" for the first
gallery_eager_count slots (passed by the partial — see
cms_gallery('name', 'carousel') which sets eager=1).
Renders
<div class="field-gallery" data-columns="auto" data-width="full">
<figure class="field-gallery__item">
<img src="…?w=520&h=650&fit=cover" alt="…" width="520" height="650"
loading="lazy" decoding="async">
</figure>
…
</div>
The on-the-fly transform (?w=…&h=…) is honoured by
cms-public/src/Controller/MediaController.php.
Theme hooks: [data-columns], [data-width]. The carousel + lightbox
partials reuse this same field — see cms_gallery() and
per-page-modules.md.
Editor: list with thumbnail previews, reorder + delete + add per row; columns + width selects.
Realistic example — voor/na gallery
{# Plain grid, theme decides columns via the data-attribute. #}
{{ field('voor_na', 'gallery') }}
{# Or wrap with cms_gallery to get the carousel/lightbox modes,
no extra Twig data wiring needed: #}
{{ cms_gallery('voor_na', 'lightbox') }}
Stored payload after the editor uploads four before/after photos and picks 4-column layout:
{
"type": "gallery",
"items": [
{ "src": "/media/uploads/voor-na-1.jpg", "alt": "Voor en na — wenkbrauwen klant 1" },
{ "src": "/media/uploads/voor-na-2.jpg", "alt": "Voor en na — wenkbrauwen klant 2" },
{ "src": "/media/uploads/voor-na-3.jpg", "alt": "Voor en na — lippen klant 3" },
{ "src": "/media/uploads/voor-na-4.jpg", "alt": "Voor en na — combi-brow klant 4" }
],
"columns": "4",
"width": "large"
}
Tip: columns: "auto" lets the gallery flow into whatever the parent
container can fit — useful for reusable partials. Pick a fixed number
(2, 3, 4) when the design is locked.
slideshow
Hero-style auto-rotating banner with text overlays + per-slide CTA.
Stored payload
{
"type": "slideshow",
"slides": [
{
"src": "/media/uploads/hero1.jpg",
"alt": "…",
"heading": "By Melanie",
"cta_kind": "none | url | page | category | product",
"cta_label": "Contact opnemen",
"cta_url": "/contact", // when cta_kind = url
"cta_page_slug": "contact", // when cta_kind = page
"cta_category_slug": "winterjassen", // when cta_kind = category
"cta_product_slug": "winterjas-dames" // when cta_kind = product
},
…
],
"interval": 5000, // ms between slides (1000–60000)
"pause_on_hover": true,
"dots": false, // dot indicators
"arrows": false, // prev/next arrows
"height": "small | medium | large | full"
}
Every internal CTA kind (page, category, product) stores a slug,
never a URL, and the slug stored is always the CANONICAL one — the slug
in the website's default language. The href is resolved server-side when
the slide renders, against the language being rendered, so a single
stored link emits /winterjassen in Dutch and /de/winterjacken in
German without the editor picking a target per language. A target that no
longer resolves (deleted, unpublished, renamed) renders no button at all,
rather than a button onto a 404.
Only top-level categories can be linked: /categories/{slug} serves
exactly those.
Per-language text. A slideshow is the one field type whose
translation override is partial. A language tab edits the slide's
heading, alt and cta_label; the image, the CTA target and the
timing knobs stay shared, so swapping a photo or re-pointing a button in
the default language reaches every language at once. The admin disables
the shared controls on a language tab, and the server keeps only those
three keys from a translation.
Renders
<div class="cms-slideshow"
data-cms-slideshow data-cms-slideshow-interval="5000"
data-cms-slideshow-pause-on-hover
data-height="large"
role="region" aria-roledescription="slideshow" aria-label="…">
<div class="cms-slideshow__track" data-cms-slideshow-track>
<div class="cms-slideshow__slide cms-slideshow__slide--active" data-cms-slideshow-slide>
<img class="cms-slideshow__image" src="…?w=1920&h=1080&fit=cover" alt="…" loading="eager">
<div class="cms-slideshow__overlay">
<h2 class="cms-slideshow__heading">By Melanie</h2>
<a class="cms-slideshow__cta" href="/contact">Contact opnemen</a>
</div>
</div>
<div class="cms-slideshow__slide" data-cms-slideshow-slide>…</div>
</div>
</div>
The partial calls cms_module('slideshow') itself, so
cms-public/public/assets/slideshow.{js,css} auto-load. Slide 1 is
loading="eager" (LCP), the rest are lazy.
Theme hooks: CSS variables on .cms-slideshow —
--cms-slideshow-fade (700ms)
--cms-slideshow-overlay-bg (rgba(0,0,0,0.25))
--cms-slideshow-overlay-fg (#fff)
--cms-slideshow-heading-size (clamp(1.75rem, 4vw, 3.25rem))
--cms-slideshow-heading-weight (500)
--cms-slideshow-cta-bg (#111)
--cms-slideshow-cta-fg (#fff)
--cms-slideshow-arrow-bg (rgba(0,0,0,0.4))
--cms-slideshow-arrow-fg (#fff)
--cms-slideshow-dot-color (rgba(255,255,255,0.5))
--cms-slideshow-dot-active (#fff)
Override per-website by setting them on .cms-slideshow itself, with a
selector more specific than the module's own and outside any @layer. A
value set on an ancestor never reaches it, because slideshow.css
declares every default on .cms-slideshow. See
tailwind-and-styles.md for
why, and cms-public/public/assets/slideshow.css for full defaults.
Heights map to viewport units: small → 40vh, medium → 60vh,
large → 80vh, full → 100vh.
Behaviour: crossfade transition, auto-rotate at interval, pause on
hover (when enabled) + on visibilitychange, no auto-rotate when the
visitor has prefers-reduced-motion: reduce or there's only one slide.
Editor: per-slide row with image picker + heading + alt + CTA radio (None / URL / Page slug); top-level controls for height, interval (ms), pause-on-hover, dots, arrows.
Realistic example — three-slide hero
{# That's it. The slideshow partial registers its own JS/CSS via
cms_module('slideshow') so no additional setup is needed. #}
{{ field('hero', 'slideshow') }}
Stored payload after the editor adds three slides, sets large height,
4-second interval, and links each slide to a page:
{
"type": "slideshow",
"slides": [
{
"src": "/media/uploads/hero-pmu.jpg",
"alt": "Permanente make-up behandeling — wenkbrauwen",
"heading": "Permanente make-up",
"cta_kind": "page",
"cta_label": "Bekijk de behandelingen",
"cta_page_slug": "pmu"
},
{
"src": "/media/uploads/hero-aftercare.jpg",
"alt": "Nazorg-instructies — kalme studio-sfeer",
"heading": "Nazorg & advies",
"cta_kind": "page",
"cta_label": "Lees meer",
"cta_page_slug": "nazorg"
},
{
"src": "/media/uploads/hero-contact.jpg",
"alt": "Studio Melanie — interieur",
"heading": "Maak een afspraak",
"cta_kind": "page",
"cta_label": "Contact",
"cta_page_slug": "contact"
}
],
"interval": 4000,
"pause_on_hover": true,
"dots": true,
"arrows": false,
"height": "large"
}
To re-skin the slideshow per-website without touching the partial, set the CSS variables in the theme's stylesheet, keyed on a class you put on a wrapper:
<section class="hero">
{{ field('hero', 'slideshow') }}
</section>
/* end of styles.src.css, outside any @layer */
.hero .cms-slideshow {
--cms-slideshow-cta-bg: var(--color-gold-deep);
--cms-slideshow-overlay-bg: rgba(20,15,11,0.4);
--cms-slideshow-heading-weight: 600;
}
An inline style="--cms-slideshow-…" on the wrapper looks equivalent and
does nothing: the wrapper's value is inherited, and the module's own
declaration on .cms-slideshow beats it.
compare
Two images in one box, revealed against each other by a divider the visitor drags. The classic before/after: a mossy roof and a cleaned one, a room before and after a renovation.
Stored payload
{
"type": "compare",
"before_src": "/media/uploads/roof-before.jpg",
"before_alt": "Dak met mos voor de reiniging",
"before_label": "Voor",
"after_src": "/media/uploads/roof-after.jpg",
"after_alt": "Hetzelfde dak na de reiniging",
"after_label": "Na",
"orientation": "horizontal | vertical",
"start": 50,
"width": "small | medium | large | full",
"align": "left | center | right",
"radius": "none | small | large"
}
start is 0-100: where the divider sits before anyone touches it. Off-centre
is a real editorial choice, 65 shows mostly the "after" with enough of the
"before" to invite the drag.
Both images are required. A pair with one image is not a degraded comparison, it is a confusing image with a line over it, so the sanitizer drops the whole field rather than storing half of one. The admin dialog says which half is still missing.
After enrichment (enrichCompare) the payload carries intrinsicWidth and
intrinsicHeight taken from the before image, which is the one in normal
flow and therefore the one that gives the figure its height. A pair with
different aspect ratios crops rather than jumping.
Renders
<figure class="cms-compare" data-cms-compare
data-orientation="horizontal" data-width="large"
data-align="center" data-radius="none"
style="--cms-compare-position: 50%;">
<img class="cms-compare__image cms-compare__image--before" src="…" alt="…">
<div class="cms-compare__after" aria-hidden="true">
<img class="cms-compare__image cms-compare__image--after" src="…" alt="">
</div>
<span class="cms-compare__label cms-compare__label--before">Voor</span>
<span class="cms-compare__label cms-compare__label--after">Na</span>
<div class="cms-compare__divider" aria-hidden="true">…</div>
<input type="range" class="cms-compare__range" data-cms-compare-range value="50">
</figure>
Works without JavaScript. The reveal is one custom property
(--cms-compare-position) written inline by the partial, and the control is a
real <input type="range">, so the divider starts where the editor left it
and remains keyboard-operable with compare.js missing. All the script adds
is the gesture everyone expects: press and drag anywhere on the image.
Theme hooks: [data-orientation], [data-width], [data-align],
[data-radius], plus the --cms-compare-* custom properties in
cms-public/public/assets/compare.css (divider colour and width, handle size
and shape, label styling, focus ring).
.cms-compare {
--cms-compare-divider-color: #22d3ee;
--cms-compare-handle-size: 52px;
--cms-compare-label-bg: rgba(0, 0, 0, 0.6);
}
Editor: two media picker slots with their own alt and label, a live preview that clips exactly as the public page does, a slider for the starting position, and selects for direction, width, alignment and corner radius. No inline edit, open via the cog button.
Realistic example
<section class="container py-12">
<h2 class="text-2xl font-semibold">Het verschil</h2>
{{ field('roof_before_after', 'compare') }}
</section>
Labels are not per-language yet.
before_label/after_labelare text living inside a media-typed field, the same gapimage.althas: the translation overlay leaves them in the default language. On a multilingual site either leave them empty and let the surrounding copy carry the meaning, or accept one language's wording on every locale.
embed
Opaque HTML for third-party embeds (maps, video, calendar widgets).
Stored payload
{ "type": "embed", "content": "<iframe src='…' …></iframe>" }
Renders
<div class="field-embed">…content as-is…</div>
The content is not sanitised on save (it's literally for embeds); the saver tightens the surface later. For now, treat embed as an admin escape hatch and don't use it for editor-shaped content.
Editor: monospace textarea via cog dialog.
Realistic example — Google Maps embed
{# Theme: just the field. Wrap with whatever framing you need. #}
<div class="aspect-[16/9] overflow-hidden rounded-lg">
{{ field('location_map', 'embed') }}
</div>
Stored payload (paste from Google Maps' "Embed a map" dialog):
{
"type": "embed",
"content": "<iframe src=\"https://www.google.com/maps/embed?pb=…\" width=\"100%\" height=\"100%\" style=\"border:0\" allowfullscreen=\"\" loading=\"lazy\" referrerpolicy=\"no-referrer-when-downgrade\"></iframe>"
}
Operators paste the entire <iframe> snippet — including any width /
height the source service includes. Wrap in a CSS container if you need
responsive sizing.
divider
Visual separator with no payload.
Renders
<hr class="field-divider">
Editor: no settings; cog button is informational.
Adding a new field type
This is a cms-core change, not a theme change. Three files:
cms-core/src/Twig/FieldExtension.php- Add to
ALLOWED_TYPES. - Optionally add an
enrich<Type>()method called fromrenderField()for server-side payload prep (intrinsic dims, link resolution, etc.).
- Add to
cms-core/src/Field/FieldSanitizer.php- Add to
KNOWN_TYPES. - Add a
case '<type>'block that produces a stable, validated payload shape from raw editor input.
- Add to
cms-core/templates/_partials/fields/<type>.html.twig- Render the payload. If the field needs a JS+CSS module to be
interactive, call
{% do cms_module('<name>') %}near the top.
- Render the payload. If the field needs a JS+CSS module to be
interactive, call
Then on the admin side (cms-admin):
cms-admin/src/types.ts— add a variant to theFieldPayloadunion.cms-admin/src/lib/pages.ts— add'<type>'toPageSchema.fields[].type.cms-admin/src/components/FieldSettingsDialog.vue— add atemplate v-else-if="type === '<type>'"block with the editor UI.
Tag a cms-core release; bump consumers; redeploy. Themes see the new type immediately — no theme change required.