Skip to content

Twig Render Reference

The render adapter generates complete HTML output for images, galleries, pagination, and other UI components. For URL-only access, see cms.media.

Image functions take three separate arguments: the object, image transform options, and collection/render context. These are separate objects — do not merge them.

cms.render.image(object, {transforms}, {context})
│ │ │
│ │ └── collection, property, loading
│ └── w, h, fit, fm, etc.
└── object ID or full object

Render a complete <img> tag with ImageWorks URL, dimensions, alt text, and lazy loading.

{# Basic image #}
{{ cms.render.image('hero') }}
{# With ImageWorks transformations #}
{{ cms.render.image('hero', {w: 800, h: 600, fit: 'crop'}) }}
{# Custom collection and property — note: separate argument from transforms #}
{{ cms.render.image('widget', {}, {collection: 'products', property: 'photo'}) }}
{# Pass object directly (recommended) #}
{{ cms.render.image(product, {w: 600}, {collection: 'products', property: 'image'}) }}
{# Eager loading (default is lazy) #}
{{ cms.render.image('hero', {}, {loading: 'eager'}) }}
ParameterTypeDefaultDescription
idOrObjectstring|array|nullrequiredObject ID or full object data
imageworksarray[]ImageWorks transformation parameters (w, h, fit, fm, etc.)
optionsarray[]Collection context: collection, property, loading

Images stored inside a card or deck field are addressed through the property option using a dot-notation path:

{# Card child — first segment is the card field, last segment is the child key #}
{{ cms.render.image('post-1', {w: 800}, {property: 'mycard.image'}) }}
{# Deck child — first segment is the deck field, then the deck-item id, then the child key #}
{{ cms.render.image('post-1', {w: 800}, {property: 'mydeck.item-3.image'}) }}

cms.render.alt() accepts the same property: 'parent.child' syntax. See cms.media → Nested images for the underlying URL convention.

Render a responsive <picture>: one <source> per modern format, each carrying a srcset of ImageWorks candidates, then an <img> fallback in the image’s own format. The fallback carries the same srcset, so a browser that ignores <picture> still picks a sensible size.

Same three arguments as image(): the object, then ImageWorks transforms, then collection/render context. The transforms are applied to every candidate in every srcset — the only thing that varies between candidates is w, which picture() sets itself.

{# Defaults: 480/768/1024/1440/1920 candidates, AVIF + WebP sources, sizes="100vw" #}
{{ cms.render.picture('hero') }}
{# An image that never spans the viewport — tell the browser, or it downloads for 100vw #}
{{ cms.render.picture(post, {}, {sizes: '(min-width: 60em) 50vw, 100vw', collection: 'blog'}) }}
{# Your own candidate widths and a single format #}
{{ cms.render.picture(post, {}, {widths: [400, 800, 1200], formats: ['webp']}) }}
{# The transforms apply to every candidate; w there is a ceiling, not a candidate #}
{{ cms.render.picture(post, {w: 1200, h: 675, fit: 'crop-focalpoint', q: 75}) }}
ParameterTypeDefaultDescription
idOrObjectstring|array|nullrequiredObject ID or full object data
imageworksarray[]ImageWorks transforms applied to every candidate — h, fit, q, a preset p, and so on. Two keys behave differently from image(): w is a ceiling on the largest candidate rather than a candidate itself, and fm is ignored, because each <source> sets its own format from the formats option
optionsarray[]The picture options below, plus the same context as image(): collection (default 'image'), property (default 'image', dotted for card/deck-nested), loading (default 'lazy') and class
OptionTypeDefaultDescription
widthsint[][480, 768, 1024, 1440, 1920]Candidate widths for every srcset. Any wider than the source are dropped and the source width joins as the largest — ImageWorks never upscales, so a wider candidate would deliver the source’s own width and mis-report it
formatsstring[]['avif', 'webp']One <source> per format, in order, best first. A format equal to the image’s own is skipped as redundant. Any of jpg, png, webp, avif
sizesstring'100vw'The sizes attribute on every <source> and the <img>. 100vw is what a browser assumes without it, so narrow it per call
collection, property, loading, classas image()Collection context, dotted nested property, lazy loading, extra class

Every w descriptor is the width ImageWorks will actually deliver for that candidate, after h and fit are applied — never the requested number. Two requested widths that clamp to one delivered width collapse into one candidate. A GIF gets no <source> children at all, since re-encoding to a still format would drop its animation. Change the defaults site-wide under imageworks.picture in config/tcms.php; see Format & Quality for the format list.

Get the alt text for an image. Falls back through alt text, EXIF data, then filename.

{{ cms.render.alt('hero') }}
{{ cms.render.alt(product, {collection: 'products', property: 'image'}) }}
{# Card child #}
{{ cms.render.alt('post-1', {property: 'mycard.image'}) }}

Render a video field property (or a local file-field video upload) as an embed, a <video> element, or a click-to-play facade — whichever fits the provider.

{# An object from the ready-made video collection: collection and property both default to "video".
Hosted providers render as a click-to-play facade (poster + play button) by default. #}
{{ cms.render.video('intro') }}
{# Hosted provider (YouTube, Vimeo, Livid, Bunny, Cloudflare, Loom, Wistia, Publitio, Jet-Stream) on your own schema #}
{{ cms.render.video(post, {property: 'promo'}) }}
{# An eager iframe instead of the facade #}
{{ cms.render.video(post, {property: 'promo', facade: false}) }}
{# Direct file URL (the `file` provider) — a muted looping background clip #}
{{ cms.render.video(post, {property: 'trailer', autoplay: true, muted: true, loop: true}) }}
{# Facade options carry into the embed built on click #}
{{ cms.render.video(post, {property: 'promo', muted: true}) }}
{# The uploaded poster resized through ImageWorks — the trailing argument, since video options lead in a video API #}
{{ cms.render.video(post, {property: 'promo'}, {w: 1200}) }}
{# A file-field value (mime starting video/) streams through the same call #}
{{ cms.render.video(post, {property: 'localClip'}) }}
OptionTypeDefaultDescription
collectionstring'video'Collection identifier
propertystring'video'Property name
autoplayboolfalseAutoplay (subject to browser muted-autoplay rules)
loopboolfalseLoop playback
mutedboolfalseMute
controlsbooltrueShow player controls (file provider only)
classstring''Extra CSS class on the wrapper
posterstring''Override poster URL — otherwise resolved via cms.media.videoPoster()
facadebooltrueClick-to-play poster with a play button; the iframe loads on click. Set false for an eager iframe. Ignored for the file provider, and for any value where no poster or thumbnail resolves, which renders the eager iframe instead (an unknown URL has no vendor thumbnail, so the poster must be uploaded)

An unknown provider URL is never modified — it renders as a generic iframe with the author’s URL exactly as pasted. See Video for the full provider table and field settings.

Render a complete gallery grid with LightGallery lightbox support.

{# Default gallery (300x200 thumbnails) #}
{{ cms.render.gallery('vacation') }}
{# Custom thumbnail size #}
{{ cms.render.gallery('vacation', {w: 150, h: 150}) }}
{# Thumbnail and full-size settings #}
{{ cms.render.gallery('vacation', {w: 150}, {w: 1200}) }}
{# With options #}
{{ cms.render.gallery('vacation', {w: 200}, {}, {
maxVisible: 8,
viewAllText: 'Show all photos',
loop: true,
download: false,
captions: true,
gridCaptions: true,
sort: 'name',
class: 'my-gallery'
}) }}

Gallery Options:

OptionTypeDefaultDescription
collectionstring'gallery'Collection identifier
propertystring'gallery'Property name
maxVisibleint0Max visible thumbnails (0 = all)
viewAllTextstring'View All'Text for the “view all” button
captionsbool|stringfalseLightbox captions: true for default, or a template string
gridCaptionsbool|stringfalseGrid captions below thumbnails
sortstring|array''Sort property (prefix with - for descending) or array of sort rules
classstring''CSS class on the gallery container
loopbooltrueLoop gallery in lightbox
downloadbooltrueShow download button in lightbox
counterbooltrueShow image counter
pluginsarray['zoom','thumbnail','fullscreen']LightGallery plugins
zoomFromOriginbooltrueAnimate the lightbox open and close from the clicked thumbnail. Set false when thumbnails are cropped to a different shape than the originals — see below

Every option that is not one of Total CMS’s own (collection, property, captions, gridCaptions, sort, class, maxVisible, viewAllText, featuredOnly) is passed straight through to LightGallery’s settings, so loop, download, counter, zoomFromOrigin, speed, mode and the rest all work as documented there.

Cropped thumbnails and the zoom animation. LightGallery’s opening animation grows the thumbnail into the full image. It assumes both have the same proportions: when the grid is square crops of landscape or portrait photos ({w: 300, h: 300, fit: 'crop'}), the thumbnail is stretched to the full image’s shape during the zoom and snaps back once the image loads (lightGallery #1698). Turn the thumbnail zoom off for those galleries and the full image fades in instead:

{{ cms.render.gallery('vacation', {w: 300, h: 300, fit: 'crop'}, {}, {zoomFromOrigin: false}) }}

For caption templates and sorting details, see totalcms.md.

Generate a hidden template element with gallery data for programmatic lightbox initialization. Use this when you want custom trigger elements instead of the default grid.

{{ cms.render.galleryLauncher('vacation') }}
<button data-gallery="gallery-vacation">View Photos</button>
{# With settings #}
{{ cms.render.galleryLauncher('vacation', {w: 300}, {w: 1920}, {
captions: true,
trigger: '.gallery-thumb',
loop: true,
download: false
}) }}

Additional Launcher Options:

OptionTypeDefaultDescription
triggerstring''CSS selector for trigger elements
galleryIdstringautoCustom gallery ID (default: {collection}-{id})
sortstring''Sort images by field (e.g., name, -name for descending)
includestring''Include filter — only images matching ALL criteria (e.g., tags:landscape,featured:true)
excludestring''Exclude filter — remove images matching ANY criteria (e.g., tags:archived)
searchstring''Full-text search across all image fields
{# Filter to only landscape-tagged images #}
{{ cms.render.galleryLauncher('vacation', {w: 300}, {w: 1920}, {
include: 'tags:landscape'
}) }}
{# Exclude archived images, sorted by name #}
{{ cms.render.galleryLauncher('vacation', {w: 300}, {w: 1920}, {
exclude: 'tags:archived',
sort: 'name'
}) }}
{# Search for images matching "sunset" #}
{{ cms.render.galleryLauncher('vacation', {w: 300}, {w: 1920}, {
search: 'sunset'
}) }}

Filtering uses the same syntax as Index Filtering, including wildcard patterns for flexible string matching.

For complete launcher usage with trigger methods and opening at specific images, see totalcms.md.

Render a single gallery image as an <img> tag with LightGallery integration attributes (data-gallery, data-gallery-image).

{# By filename #}
{{ cms.render.galleryImage('vacation', 'sunset.jpg', {w: 300, h: 200}) }}
{# Dynamic selectors #}
{{ cms.render.galleryImage('vacation', 'first', {w: 400}) }}
{{ cms.render.galleryImage('vacation', 'last') }}
{{ cms.render.galleryImage('vacation', 'random') }}
{{ cms.render.galleryImage('vacation', 'featured') }}

Get the alt text for a specific gallery image.

{{ cms.render.galleryAlt('vacation', 'sunset.jpg') }}
{{ cms.render.galleryAlt('vacation', 'sunset.jpg', {collection: 'photos'}) }}

Get the caption for a gallery image, with optional template rendering.

{# Default caption (alt text fallback chain) #}
{{ cms.render.galleryCaption('vacation', 'sunset.jpg') }}
{# With template #}
{{ cms.render.galleryCaption('vacation', 'sunset.jpg', {}, '<h4>{alt}</h4><p>{exif.camera}</p>') }}

Render simple Previous/Next pagination links.

{{ cms.render.paginationSimple(totalItems, currentPage, itemsPerPage) }}
{# With custom labels and page key #}
{{ cms.render.paginationSimple(items|length, page, 10, 'page', 'Prev', 'Next') }}
{# Preserve query string parameters #}
{{ cms.render.paginationSimple(total, page, 10, 'p', 'Previous', 'Next', {sort: 'date', q: query}) }}
ParameterTypeDefaultDescription
totalObjectsintrequiredTotal number of items
currentPageintrequiredCurrent page number
pageLimitintrequiredItems per page
pageKeystring'p'Query parameter name for page number
prevContentstring'Previous'Previous button text/HTML
nextContentstring'Next'Next button text/HTML
getDataarray[]Additional query parameters to preserve

Render full pagination with numbered page links, plus Previous/Next.

{{ cms.render.paginationFull(totalItems, currentPage, itemsPerPage) }}
{{ cms.render.paginationFull(items|length, page, 10, 'p', '← Previous', 'Next →', {sort: 'date'}) }}

Parameters are identical to paginationSimple().

Generate an HTMX-powered “load more” trigger for paginated collection loading.

{{ cms.render.loadMore('blog', {template: 'blog/card.twig', limit: 10}) }}
{# Render first page server-side + HTMX for the rest #}
{{ cms.render.loadMore('blog', {template: 'blog/card.twig', limit: 10, load: true}) }}
{# With empty state for filtered results #}
{{ cms.render.loadMore('blog', {
template: 'blog/card.twig',
include: 'published:true',
empty: '<p>No posts found.</p>'
}) }}

See Load More Documentation for full options and examples.

Generate an HTMX-powered “load more” trigger for paginated DataView loading.

{{ cms.render.loadMoreDataView('recent-posts', {template: 'blog/card.twig', limit: 10}) }}

Generate a standalone HTMX button that appends items into an external container. Unlike loadMore(), the button can be placed anywhere on the page.

<div id="blog-feed"></div>
{{ cms.render.loadMoreButton('blog', {
target: '#blog-feed',
template: 'blog/card.twig',
limit: 10,
load: true
}) }}

See Load More Documentation for full options and examples.

Generate a standalone HTMX button for paginated DataView loading into an external container.

<div id="feed"></div>
{{ cms.render.loadMoreDataViewButton('recent-posts', {
target: '#feed',
template: 'cards/item.twig',
limit: 20
}) }}

The HTML-fragment endpoints behind Load More, exposed for your own hx-get and hx-post attributes. Each returns a raw URL; Twig’s autoescape handles it inside an attribute. See HTMX Recipes for complete examples.

{{ cms.render.queryUrl('blog', {template: 'blog/card', limit: 12, search: 'php'}) }}

Options: template (required), limit, offset, sort, include, exclude, search, mode.

{{ cms.render.viewQueryUrl('recent-posts', {template: 'cards/item', limit: 10}) }}
{{ cms.render.objectFragmentUrl('products', product.id, {template: 'products/quick-view'}) }}
{{ cms.render.saveUrl('contact', {template: 'forms/thanks'}) }} {# hx-post target #}
{{ cms.render.saveUrl('posts', {id: post.id, template: 'posts/card'}) }} {# hx-put / hx-patch target #}
{{ cms.render.incrementUrl('posts', post.id, 'likes') }}

Render an interactive file browser for depot properties. Displays files in a nested folder tree with filtering, previews, and download links.

{# Basic depot browser #}
{{ cms.render.depotBrowser('my-object') }}
{# With options #}
{{ cms.render.depotBrowser('my-object', {
collection: 'documents',
property: 'files',
filter: true,
preview: true,
tags: true,
comments: true,
class: 'my-depot'
}) }}
{# Filter by tags #}
{{ cms.render.depotBrowser('my-object', {filterTags: ['important', 'public']}) }}
{# Flat file list (no folders) #}
{{ cms.render.depotBrowser('my-object', {folders: false}) }}

Depot Browser Options:

OptionTypeDefaultDescription
collectionstring'depot'Collection identifier
propertystring'depot'Property name
filterboolfalseShow search/filter input
previewboolfalseShow preview buttons with modal
commentsboolfalseDisplay file comments
downloadbooltrueMake files downloadable (vs stream)
tagsboolfalseDisplay file tags
foldersbooltruePreserve folder structure
humanizebooltrueConvert filenames to title case
classstring''Custom CSS class
reverseSortboolfalseReverse file sort order
filterTagsarray[]Filter files by tags (OR logic, case-insensitive)

Render a clone dialog for duplicating objects in a collection. Used in the admin interface.

{{ cms.render.cloneDialog('blog') }}

The render adapter includes a grid sub-object with helper methods for content grids:

{{ cms.render.grid.date(item.date, 'M j, Y') }} {# Format date with fallback #}
{{ cms.render.grid.tags(item.tags, '/blog/tag') }} {# Render tag list with links #}
{{ cms.render.grid.excerpt(item.summary, 160) }} {# Generate text excerpt #}
{{ cms.render.grid.price(item.price) }} {# Format price #}
{{ cms.render.grid.meta(item.author) }} {# Render metadata (author, date) #}

See cmsgrid tag for the {% cmsgrid %} template tag documentation.