Portfolio module
Module id: portfolio. Dependencies: none.
What it adds:
- Collections: named groups of photographs a visitor switches
between (
Bruiloften,Portret,Zakelijk). - A Portfolio item in the admin sidebar (Content section) where you create, rename, reorder, publish and unpublish them.
- Three theme helpers, and a ready-made switcher the theme can drop in with one call.
Media and media folders are core, not part of this module, so a site can run a portfolio without the catalog, the blog, or anything else.
A collection is a media folder
There is no separate photo list to curate. A collection points at one media folder, and the folder's contents in the folder's own order are the group. Adding a photograph to a portfolio is dropping a file into a folder in the Media library, and reordering the group is the drag-reorder that already exists there.
That is the whole model, and it is worth stating what it rules out: a photo cannot be in two collections at once, and there is no per-collection crop, caption override, or ordering. What Media holds is what the visitor sees. Alt text and caption come from the Media row, so filling them in once serves both the library and the portfolio.
What operators set
| Field | What it does |
|---|---|
| Name | The pill label. Translatable per locale. |
| Slug | Generated from the name, unique per website. It is the #deeplink and the argument to cms_portfolio_collection(). |
| Media folder | The folder whose contents are the group. May be left empty, in which case the collection renders nowhere. |
| Published | Off hides it from the site while keeping it in the admin. |
| Order | Drag to set pill order. |
Empty collections never render
A collection with no photographs, an unpublished one, and one whose folder was deleted are all treated the same way: they do not appear. Not as an empty pill, not as a blank panel.
This is deliberate rather than incidental. A pill that switches to nothing looks like a broken site, and browsers restore tab and radio state across reloads, so a visitor can land straight on the empty one with no clue what went wrong. If there are no photographs, there is no group.
The practical consequence for operators: a new collection stays invisible until its folder has at least one image in it. If a collection you created is not on the site, check the folder before anything else. Non-image files in the folder (documents, video) are skipped rather than rendered as broken tiles, so a folder holding only a PDF still counts as empty.
A folder is read up to 500 images. Past that the tail is ignored, which is far above any real group and exists only to stop a mis-targeted folder taking a page down.
Translations
The collection name is per locale, edited on the collection itself. An unfilled locale falls back to the base name rather than rendering blank, so a second language costs nothing until you want it.
The slug does not translate. One collection has one slug across every locale, so a deep link keeps working when a visitor switches language.
What a theme has to do
The short version is one call:
{{ cms_portfolio() }}
That renders the pills, the panels, and the photographs, registers its own JS and CSS, and returns an empty string when the module is off or nothing is publishable. A shared theme can call it unconditionally.
Options:
| Option | Default | What it does |
|---|---|---|
heading |
none | An <h2> above the pills |
columns |
4 |
Masonry column count at the widest breakpoint |
radius |
none | Corner radius on the photographs, any CSS length |
lightbox |
true |
Click a photo to open it full screen |
aria_label |
Portfolio |
Accessible name for the tablist |
{{ cms_portfolio({ heading: 'Werk', columns: 3, radius: '16px', lightbox: false }) }}
radius is an option rather than a CSS variable you set in your own
stylesheet because portfolio.css loads after the theme's sheet: a
theme setting --cms-portfolio-radius at equal specificity loses to the
module's default. Passing it here wins outright and saves you a
specificity fight.
For full control, build the markup yourself:
{% for c in cms_portfolio_collections() %}
<section id="{{ c.slug }}">
<h2>{{ c.name }} <small>{{ c.count }}</small></h2>
{% for p in c.photos %}
<img src="{{ p.src }}" alt="{{ p.alt }}"
width="{{ p.width }}" height="{{ p.height }}"
loading="{{ p.loading }}">
{% endfor %}
</section>
{% endfor %}
Each photo is a map of src, alt, caption, width, height,
loading. loading is already eager for the first four of a group and
lazy for the rest, so passing it straight through gets the LCP
behaviour right without thinking about it.
See twig-helpers.md for the full signatures.
Accessibility and no-JS behaviour
The bundled switcher is a real tablist: role="tab" buttons,
role="tabpanel" panels, arrow-key navigation, and a #slug deep link
that opens the right group.
Without JavaScript every panel is visible and the page reads as a series of labelled sections. The module then hides all but one and wires up the tabs. A visitor with a broken script sees all the photographs rather than none.
One collection renders as a plain grid with no tablist, because a lone tab is furniture.
Overridable templates
@GsidCmsCore/portfolio/_switcher.html.twig → @website/portfolio/_switcher.html.twig
Overriding replaces the entire switcher, tablist and JS hooks included.
Before you do, try the --cms-portfolio-* custom properties in
cms-public/public/assets/portfolio.css, which cover most of what a theme
wants to change without giving up the accessibility work.