Layout Framework Reference
Every section of this page is built the way you build a real page in Contao: each article is a .row and each content element is a column (col-1-2, col-1-3, …). Open any article in the back end to see exactly how it was made.
Where gutters matter, the row carries an amber striped background — any amber you can see is real empty space between columns.
Each section is tagged with the stylesheet that owns it — the small blue-keyed filename above the demo — so this page doubles as a lookup for where to make a change.
Column widths
Set on the content element. Widths are exact: any set of fractions that adds up to 100% fills the row precisely, gutter included.
Responsive behaviour
Narrow the window to watch these change. Every two-column layout reaches an even 50/50 on tablet and only stacks on phones.
| Layout | Desktop | Tablet ≤992 | Phone ≤600 |
|---|---|---|---|
| col-1-2 pair | 50 / 50 | 50 / 50 | stacked |
| col-3-4 + col-1-4 | 75 / 25 | 50 / 50 | stacked |
| col-2-3 + col-1-3 | 66 / 33 | 50 / 50 | stacked |
| col-3-5 + col-2-5 | 60 / 40 | 50 / 50 | stacked |
| col-1-4 ×4 | 4-up | 2-up | stacked |
| col-1-3 ×3 | 3-up | stacked | stacked |
Thirds go straight to stacked because three columns cannot reach an even 2-up without orphaning one.
no-stack to the row to hold columns side by side at every width. These three stay 3-up even on a phone.
Gutter width
Set on the row (the article). Changing the gutter automatically adjusts the column widths so the row still fits exactly.
gap-40 is the default, so a plain row already behaves like gap-40.
gap-fluid-sm, gap-fluid-md and gap-fluid-lg — gap-fluid is the original name for the middle tier and still works, so the two are interchangeable. Resize the window to watch these move.
gap-x-* changes only the space between columns; gap-y-* only the space between wrapped lines, so it shows only when a row wraps. Both come in the same steps as gap-* (0/16/24/40/60) plus -fluid, and combine with a gap class: row gap-24 gap-y-60 is 24 across, 60 down.
pxc-24.
Readable single columns
Cap a single column so long text keeps a comfortable line length instead of stretching across a wide screen.
Alignment
Text alignment goes on the content element; vertical alignment goes on the row.
Cards
A card is simply a column with a background or border. Real gutters mean the cards separate properly with no extra wrapper element.
Card one
The background sits directly on the column.
Card two
The gutter is real empty space, so the cards read as separate objects.
Card three
No inner wrapper needed.
Spacing utilities
Apply these to blocks, sections and columns — not to individual paragraphs. Paragraph rhythm belongs to the site’s typography, not to this framework.
| Class | Effect |
|---|---|
| mb-0 … mb-80 | Margin below (steps 0, 8, 12, 16, 24, 40, 60, 80) |
| mt-0 … mt-80 | Margin above, same steps |
| my-24, my-60 | Margin above and below together |
| section-space | Standard separation between major sections |
| section-space-top / -bottom | One side only |
The numbers are steps, not pixels. The larger steps scale with the viewport, so mb-40 is smaller on a phone than on a desktop.
Project tokens
Two token blocks re-tune a whole site without touching any class names: PROJECT TOKENS at the top of framework_v3.css for layout, and :root in website.css for brand. The numbers in class names are steps, not pixels — set --gutter-40 to 32px and gap-40 becomes 32px.
| Token | Default | Controls |
|---|---|---|
| --page-gutter | clamp(16px, 4vw, 60px) | Space between content and the screen edge |
| --container-max | 1800px | Maximum content width |
Gutter scale — drives every gap-* utility, the row default and the responsive overrides.
| Token | Default | Used by |
|---|---|---|
| --gutter-0 / 16 / 24 / 40 / 60 | 0 / 16 / 24 / 40 / 60px | gap-0 … gap-60, pxc-0 … pxc-60 |
| --gutter-fluid-sm | clamp(8px, 1.5vw, 16px) | gap-fluid-sm |
| --gutter-fluid-md | clamp(12px, 2vw, 32px) | gap-fluid-md / gap-fluid |
| --gutter-fluid-lg | clamp(24px, 4vw, 64px) | gap-fluid-lg |
| --gutter-fluid-axis | clamp(12px, 2vw, 40px) | gap-x-fluid, gap-y-fluid |
| --row-gap-x / --row-gap-y | --gutter-40 | The default gutter on every row |
Column inner padding — a separate fluid scale, because inner padding runs wider than a gutter at the top end.
| Token | Default | Used by |
|---|---|---|
| --col-pad-fluid-sm | clamp(8px, 1.5vw, 24px) | pxc-fluid-sm |
| --col-pad-fluid | clamp(16px, 3vw, 60px) | pxc-fluid |
| --col-pad-fluid-lg | clamp(24px, 4vw, 80px) | pxc-fluid-lg |
Other scales
| Token | Default | Controls |
|---|---|---|
| --space-* | 0 – 80 | The spacing scale behind mb-* and mt-*. Kept separate from the gutter scale because these steps are fluid by design. |
| --single-sm / md / lg | 720 / 880 / 1040px | Readable single-column widths |
Typography and Color tokens — these live in the :root block of website.css, which must be in every page layout: nothing else defines them, so without it every var(--prime) resolves to nothing.
| Token | Default | Controls |
|---|---|---|
| --text | var(--black) | Body text color |
| --prime | #001078 | Primary brand color |
| --second | #E02200 | Secondary brand color — buttons and hover states |
| --third | commented out | Optional third brand color. Nothing reads it by default, so uncomment only if the brand has one |
| --background | var(--white) | Page background. Wired to #container, so changing it here changes the page |
| --stroke | #333 | Borders and rules. A color, not a shorthand — every usage site supplies its own width and style |
| --grey-dark / --grey-light | #404040 / #ededed | Derived shades. Re-derive these if the brand colors change |
| --prime-light | #001ede | Derived from --prime |
| --heading-gradient-from / -to | #032A42 / #2C647A | The h1 gradient fill |
| --type-body | ibm-plex-sans | Body typeface |
| --type-display | begum | Display typeface, used by headings |
Fonts are not bundled. Both defaults are Adobe Fonts and need a project kit added to the page layout — or swap them for whatever the site uses.
Containers and full-bleed
A container gives content a maximum width, centres it, and holds it away from the screen edge.
| Class | What it does |
|---|---|
| container | Caps width at --container-max (1800px), centres it, adds the page gutter |
| container-fluid | Full width with the page gutter only — no maximum |
| bleed-x | Breaks back out of the gutter, for edge-to-edge bands |
Contao’s main content column already acts as the container — the framework applies the max-width, centring and page gutter to #main > .inside for you. So an article is already inside a container.
Do not nest one inside another. A container placed inside the main column adds a second page gutter, indenting that block further than everything around it. Reach for container and container-fluid in custom templates or outside the main column — headers, footers and full-width bands.
Note for this page: bleed-x is designed for a full-width, single-column layout. This reference page has a sidebar, so the band below is clipped at the content column instead of reaching the screen edge — which is the lesson: next to a sidebar there is no page gutter for it to escape.
.bleed-x breaks out of the container and .bg-* paints it. They are separate on purpose — either is useful alone, and together they make a full-width band. This replaces the old .full_width, which did both at once using two 9999em pseudo-elements.
Content elements
Real Contao content elements, so this markup is exactly what an editor produces. Each demo below names the stylesheet it lives in: image floats and video embeds come from website_standard.css, while galleries and tables have their own gallery.css and tables.css.
Image floated left
This paragraph is deliberately long so the wrap is unmistakable. If the float is working, these words run alongside the image, line after line, hugging its edge — and then, once the text passes the bottom of the image, it reflows to the full width of the column. That reflow is the thing to watch for: it is exactly what a float does and exactly what flexbox cannot do, because a flex item keeps the text locked in its own column no matter how far it runs.
A second paragraph, to push well past the bottom edge of the picture. By this point the lines should be spanning the entire width of the content area rather than stopping short at the image. If instead the text is still confined to a narrow column beside the picture, with empty space underneath the image, then the element is still being laid out with flex rather than float.
A third paragraph for good measure. On a narrow screen none of this applies: below 600px the image unfloats and takes its own line, because there is not enough width left over for a wrapped column to be readable. Drag the window narrow and you should see it switch.
Image floated right
This paragraph is deliberately long so the wrap is unmistakable. If the float is working, these words run alongside the image, line after line, hugging its edge — and then, once the text passes the bottom of the image, it reflows to the full width of the column. That reflow is the thing to watch for: it is exactly what a float does and exactly what flexbox cannot do, because a flex item keeps the text locked in its own column no matter how far it runs.
A second paragraph, to push well past the bottom edge of the picture. By this point the lines should be spanning the entire width of the content area rather than stopping short at the image. If instead the text is still confined to a narrow column beside the picture, with empty space underneath the image, then the element is still being laid out with flex rather than float.
A third paragraph for good measure. On a narrow screen none of this applies: below 600px the image unfloats and takes its own line, because there is not enough width left over for a wrapped column to be readable. Drag the window narrow and you should see it switch.
Image above the text
The default: the image takes its own line and text follows underneath.
Image beside text
A plain image element in one column and text in the other — the pattern most layouts actually use, rather than a float.
YouTube — 16:9
YouTube — 9:16 (portrait)
YouTube — 1:1 (square)
Pasted embeds
Clients often skip the video element and paste the embed code from YouTube straight into a text or unfiltered HTML element. That markup has no aspect class and no content-youtube wrapper, so the rules above never reach it — the ratio exists only in the tag’s own width and height attributes.
website_standard.css handles it in three layers:
| Layer | What it does |
|---|---|
| Contain | Nothing pasted may break out of its column |
| Known hosts | YouTube, Vimeo, Loom, Wistia and friends go fluid at 16:9. Matched on src on purpose — styling every iframe would wreck a map or a 152px Spotify player, and those keep their own height |
| Real ratio | Where the browser supports typed attr(), the true ratio is read from the embed’s own attributes, so a pasted 4:3 or portrait embed is correct rather than merely contained. Everywhere else the 16:9 assumption stands |
Watch the wrapper. The HTML and unfiltered HTML elements emit their markup with no wrapping div, so the embed becomes a direct child of the article — any column class set in the back end is silently dropped, because there is nothing to put it on. The CSS gives it a full line instead. For a host that is not listed, wrap it in <div class="embed-fluid">; add style="--video-ratio: 4/3" to that div if it is not 16:9.
The embed below is a real, unmodified YouTube paste in an unfiltered HTML element.
Self-hosted player
The player element serves a file from the file manager rather than embedding a third party, and Contao renders <video> or <audio> depending on the file’s mime type. The width and height fields become HTML attributes on the media element — the same trap as the YouTube iframe above — so the width has to be released for it to fill its column.
Unlike an iframe a <video> has an intrinsic size, so no ratio is invented for it: browsers derive one from those attributes as they do for an image. Forcing a ratio here would squash a 4:3 clip into 16:9.
Self-hosted video
Download element
Screen-reader text
This sentence contains a (visually hidden but announced)hidden span — invisible on screen, still read aloud.
Table element
| Class | What it does | Layer |
|---|---|---|
| col-1-2 | Half-width column | framework |
| gap-40 | 40px gutter | framework |
| center_text | Centres text | framework |
| btn | Button styling | brand |
Wide table — scrolls instead of clipping
| Token | Default | Controls | Scale | Notes | Layer |
|---|---|---|---|---|---|
| --gutter-40 | 40px | Row gutter | gutter | default | framework |
| --space-24 | 24px | Block rhythm | space | fixed | framework |
| --page-gutter | clamp(16,4vw,60) | Screen edge | page | fluid | framework |
| --container-max | 1800px | Page width | page | single dial | framework |
| --prime | #001078 | Brand colour | brand | per site | website |
Accordion
Contao’s accordion element emits handorgel__* markup and loads the handorgel component’s own JS and base CSS. That component is not a core dependency — without composer require contao-components/handorgel both files 404 and the element renders as dead buttons with every panel expanded.
accordion.css brands it rather than replacing it, because the base stylesheet owns the height transition that makes a panel open at all. The open/closed chevron is driven from aria-expanded, so the visual state and the accessible state read the same source and cannot disagree — and state is not signalled by colour alone.
Rows, columns, gutters and the spacing utilities live in framework_v3.css. It never sets typography and never names a CMS selector — both boundaries were crossed once and cost real debugging time.
handorgel binds a transitionend listener on the panel and applies its opened class from that callback. Setting transition: none under reduced motion would mean the event never fires and the panel never finishes opening, so the duration is collapsed to 1ms instead.
Each panel is a real content element nested inside the accordion, and its section headline becomes the button. Anything can go in a panel — text, an image, a table.
Buttons
The button list names intents rather than the button element, so a control rendered by a third-party component is left alone.
| Selector | Use |
|---|---|
| .btn a | Wrap the link: <p class="btn"><a>…</a></p> |
| a.btn | The class straight on the link |
| button[type="submit"] input[type="submit"] .submit | Form submits. Contao sets both the type and the class |
| .content-download .download-element a | Download element |
| .layout_latest p.more a | News “more” link |
A bare <button> is deliberately not styled. It used to be, and it caught every control any third-party component rendered — on this page that was one real call to action against eleven it should not have touched, and three stylesheets were each undoing it. [type="submit"] separates them because an attribute selector only matches an attribute that is present: submit is the default for <button>, but component controls set type="button" or omit it. Add .btn to opt a hand-written button in.
Code
The code element highlights server-side with scrivo/highlight.php and emits <pre><code class="hljs …">. A long line cannot wrap, so the question is always whether it scrolls or is silently cut off — html and #wrapper both use overflow: clip, which has no scrollbar.
A CSS sample
.row > * {
/* deliberately long, to prove the block scrolls rather than being clipped */
flex: 0 1 100%;
padding-inline: var(--col-pad-x);
min-width: 0;
}
.col-1-2 { flex: 0 0 calc(50% - var(--gap-x, 0px) * 0.5); }
.col-1-3 { flex: 0 0 calc(100% / 3 - var(--gap-x, 0px) * 2 / 3); }
Lists
The list and description list elements emit plain <ul>, <ol> and <dl> with no classes of their own — the same markup a list typed into a text element produces. So they are styled in website.css with the rest of the typography rather than in a component file: two files styling one <li> would race at similar specificity and drift apart, and a list has no layout of its own to justify a component.
Left on browser defaults these indent by 40px and <dd> gets that indent and nothing else, which reads as a stray margin rather than a definition.
Unordered
- A plain item
- An item with a nested list
- nested one
- nested two
- The last item — its margin is removed so it does not double up with the list
Ordered
- Markers stay at the text colour
- Colouring them costs legibility where the number carries meaning
- The tuning note in website.css says how to change it
Description list
- Term
- The definition sits under its term, indent reset
- --container-max
- 1800px — the single dial for page width
- --video-tall-max
- 80vh — the ceiling shared by portrait media
1. Put width classes on the content element; put gutter, alignment and stacking classes on the article.
2. Do not add margin utilities to paragraphs — use them on blocks and columns.
3. Pick a width that adds up to 100% across the row, or the row will be deliberately ragged.
4. Use no-stack only when columns genuinely must stay side by side on a phone.