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.

col-1-250%
col-1-250%
col-1-333.3%
col-1-333.3%
col-1-333.3%
col-1-425%
col-1-425%
col-1-425%
col-1-425%
col-2020%
col-2020%
col-2020%
col-2020%
col-2020%
col-2-366.6% — main content
col-1-333.3% — sidebar
col-3-475%
col-1-425%
col-3-560%
col-2-540%
col-3030%
col-3030%
col-2-540%
col-autoas wide as its content
colfills whatever is left
col-1-1100% — always takes its own line
col-1-1so two of them stack

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.

LayoutDesktopTablet ≤992Phone ≤600
col-1-2 pair50 / 5050 / 50stacked
col-3-4 + col-1-475 / 2550 / 50stacked
col-2-3 + col-1-366 / 3350 / 50stacked
col-3-5 + col-2-560 / 4050 / 50stacked
col-1-4 ×44-up2-upstacked
col-1-3 ×33-upstackedstacked

Thirds go straight to stacked because three columns cannot reach an even 2-up without orphaning one.

no-stackAdd no-stack to the row to hold columns side by side at every width. These three stay 3-up even on a phone.
col-1-3never stacks
col-1-3never stacks
col-1-3never stacks

Gutter width

Set on the row (the article). Changing the gutter automatically adjusts the column widths so the row still fits exactly.

Fixed stepsA specific gutter in pixels, the same at every screen size. Use these when the spacing has to match a design exactly. gap-40 is the default, so a plain row already behaves like gap-40.
gap-0no gutter
gap-0no gutter
gap-0no gutter
gap-1616px
gap-1616px
gap-1616px
gap-2424px
gap-2424px
gap-2424px
gap-4040px — the default
gap-4040px — the default
gap-4040px — the default
gap-6060px
gap-6060px
gap-6060px
Fluid stepsThe gutter scales with the viewport, so spacing tightens on small screens and opens up on large ones. Prefer these for marketing and editorial layouts. The three tiers are 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-fluid-smclamp(8px, 1.5vw, 16px)
gap-fluid-smclamp(8px, 1.5vw, 16px)
gap-fluid-smclamp(8px, 1.5vw, 16px)
gap-fluid-mdclamp(12px, 2vw, 32px) — same as gap-fluid
gap-fluid-mdclamp(12px, 2vw, 32px) — same as gap-fluid
gap-fluid-mdclamp(12px, 2vw, 32px) — same as gap-fluid
gap-fluid-lgclamp(24px, 4vw, 64px)
gap-fluid-lgclamp(24px, 4vw, 64px)
gap-fluid-lgclamp(24px, 4vw, 64px)
One axis onlyUse these when horizontal and vertical rhythm should differ. 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.
gap-24 gap-y-6024 across
gap-24 gap-y-6024 across
gap-24 gap-y-6024 across
gap-24 gap-y-6060 down
gap-24 gap-y-6060 down
gap-24 gap-y-6060 down
gap-x-fluidhorizontal only
gap-x-fluidhorizontal only
gap-x-fluidhorizontal only
gap-y-fluidvertical only
gap-y-fluidvertical only
gap-y-fluidvertical only
second linethe gap above this line is the one that changes
second line
second line
pxc-* — inner paddingAdds padding inside each column without changing its outer width. Useful when columns have backgrounds. This row uses pxc-24.
col-1-2text is inset by pxc-24
col-1-2outer width unchanged

Readable single columns

Cap a single column so long text keeps a comfortable line length instead of stretching across a wide screen.

single-md · center_elementcapped at 880px and centred — this is the width to use for long-form copy, because a full-width paragraph on a large monitor is genuinely hard to read. Available as single-sm (720px), single-md (880px) and single-lg (1040px).

Alignment

Text alignment goes on the content element; vertical alignment goes on the row.

align-lefttext left
center_texttext centred
align-righttext right
vert_centerthis column is deliberately tall
col-1-2and this one is centred against it
hor_centerGoes on the row. Centres the columns horizontally when they do not fill the whole width — useful for a short row you do not want stuck to the left edge. These two quarters only fill half the row, so the leftover space is split evenly on both sides.
col-1-4centred as a group
col-1-4centred as a group

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.

ClassEffect
mb-0 … mb-80Margin below (steps 0, 8, 12, 16, 24, 40, 60, 80)
mt-0 … mt-80Margin above, same steps
my-24, my-60Margin above and below together
section-spaceStandard separation between major sections
section-space-top / -bottomOne 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.

TokenDefaultControls
--page-gutterclamp(16px, 4vw, 60px)Space between content and the screen edge
--container-max1800pxMaximum content width

Gutter scale — drives every gap-* utility, the row default and the responsive overrides.

TokenDefaultUsed by
--gutter-0 / 16 / 24 / 40 / 600 / 16 / 24 / 40 / 60pxgap-0 … gap-60, pxc-0 … pxc-60
--gutter-fluid-smclamp(8px, 1.5vw, 16px)gap-fluid-sm
--gutter-fluid-mdclamp(12px, 2vw, 32px)gap-fluid-md / gap-fluid
--gutter-fluid-lgclamp(24px, 4vw, 64px)gap-fluid-lg
--gutter-fluid-axisclamp(12px, 2vw, 40px)gap-x-fluid, gap-y-fluid
--row-gap-x / --row-gap-y--gutter-40The default gutter on every row

Column inner padding — a separate fluid scale, because inner padding runs wider than a gutter at the top end.

TokenDefaultUsed by
--col-pad-fluid-smclamp(8px, 1.5vw, 24px)pxc-fluid-sm
--col-pad-fluidclamp(16px, 3vw, 60px)pxc-fluid
--col-pad-fluid-lgclamp(24px, 4vw, 80px)pxc-fluid-lg

Other scales

TokenDefaultControls
--space-*0 – 80The spacing scale behind mb-* and mt-*. Kept separate from the gutter scale because these steps are fluid by design.
--single-sm / md / lg720 / 880 / 1040pxReadable 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.

TokenDefaultControls
--textvar(--black)Body text color
--prime#001078Primary brand color
--second#E02200Secondary brand color — buttons and hover states
--thirdcommented outOptional third brand color. Nothing reads it by default, so uncomment only if the brand has one
--backgroundvar(--white)Page background. Wired to #container, so changing it here changes the page
--stroke#333Borders and rules. A color, not a shorthand — every usage site supplies its own width and style
--grey-dark / --grey-light#404040 / #edededDerived shades. Re-derive these if the brand colors change
--prime-light#001edeDerived from --prime
--heading-gradient-from / -to#032A42 / #2C647AThe h1 gradient fill
--type-bodyibm-plex-sansBody typeface
--type-displaybegumDisplay 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.

ClassWhat it does
containerCaps width at --container-max (1800px), centres it, adds the page gutter
container-fluidFull width with the page gutter only — no maximum
bleed-xBreaks back out of the gutter, for edge-to-edge bands
You rarely need these on a Contao page

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.

bleed-xCancels the container gutter with negative side margins, so a block can run wider than the text around it. Use it for full-width background bands; avoid it inside anything that already has its own padding.

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.

col-1-1 · bleed-xthis band breaks out past the container gutter
Background bands

.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.

bleed-x bg-lightbreaks out, light grey
bleed-x bg-primebreaks out, brand colour — text flips to white automatically

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.

Landscape test image
floating: left

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.

Portrait test image
floating: right

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.

Wide test image
floating: above

Image above the text

The default: the image takes its own line and text follows underneath.

Square test image
A plain image element in col-1-2

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

content-youtube with aspect--16:9

YouTube — 9:16 (portrait)

content-youtube with aspect--16:9

YouTube — 1:1 (square)

content-youtube with aspect--16:9

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:

LayerWhat it does
ContainNothing pasted may break out of its column
Known hostsYouTube, 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 ratioWhere 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.

A generated 3-second tone — the caption becomes a figcaption

Self-hosted video

A 2160x3840 portrait clip — the case that needs a height ceiling

Download element

Screen-reader text

This sentence contains a 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.

SelectorUse
.btn aWrap the link: <p class="btn"><a>…</a></p>
a.btnThe 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 aDownload element
.layout_latest p.more aNews “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.

.btn wrapping a link

a.btn on the link

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

  1. Markers stay at the text colour
  2. Colouring them costs legibility where the number carries meaning
  3. 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
House rules

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.