Tutorial 4: Create a reusable layout
Objective: give every page of a small studio website the same frame — an announcement bar, a sticky header with a responsive menu, and a footer — defined once in a layout.
Prerequisites: Tutorial 3. Start a fresh project with iwc create lumen-studio --yes, or follow along in your own.
The finished project:
layouts/
├── components/
│ ├── site-footer.component.iwc
│ └── site-header.component.iwc
├── layouts/
│ └── site.layout.iwc
├── styles/
│ └── main.css
├── tests/
│ └── layout.test.iwc
├── ui/
│ ├── home.ui.iwc
│ ├── not-found.ui.iwc
│ └── work.ui.iwc
├── app.iwc
└── package.iwcStep 1: The layout
Create layouts/site.layout.iwc:
{
"name": "SiteLayout",
"className": "site",
"slots": {
"announcement": {
"type": "container",
"className": "announcement",
"elements": [{ "type": "text", "variant": "span", "content": "New: weekend workshops — book now" }]
},
"header": "site-header.component.iwc",
"content": "PAGE_CONTENT",
"footer": "site-footer.component.iwc"
},
"structure": {
"header": { "position": "sticky" },
"announcement": { "mobile": "hidden" }
}
}| Property | Meaning |
|---|---|
slots | The regions, in display order. A value is a component file, an inline element, or "PAGE_CONTENT" — where each screen's sections go. PAGE_CONTENT must appear exactly once |
structure | Per-slot behaviour: position: "sticky" keeps the header in view; mobile: "hidden" hides the announcement on phones |
className | A class on the layout's root, for your CSS |
Slot names are yours. header, footer and sidebar get their semantic tags, and every slot gets the class iwc-slot-<name>. A "Skip to content" link for keyboard users is added for you.
Step 2: The header and footer
{
"name": "SiteHeader",
"root": {
"type": "navbar",
"className": "site-header",
"collapseAt": "mobile",
"elements": [{ "type": "link", "label": "Lumen Studio", "target": "/", "className": "brand" }],
"items": [
{ "label": "Work", "target": "/work" },
{ "label": "Services", "items": [
{ "label": "Branding", "target": "/work#branding" },
{ "label": "Websites", "target": "/work#websites" }
] },
{ "label": "Contact", "target": "mailto:hello@lumen.example" }
]
}
}A navbar keeps its elements (the brand) always visible and folds its items behind a menu button below collapseAt. An item with its own items becomes a dropdown. The link to the current page gets aria-current="page".
{
"name": "SiteFooter",
"root": {
"type": "footer",
"className": "site-footer",
"layout": { "justify": "between", "align": "center", "wrap": true, "gap": "12px" },
"elements": [
{ "type": "text", "variant": "caption", "content": "© Lumen Studio · Jaipur" },
{ "type": "nav", "className": "footer-links", "items": [
{ "label": "Work", "target": "/work" },
{ "label": "Instagram", "target": "https://instagram.com" }
] }
]
}
}Step 3: Pages that use it
Every screen that names the layout gets the frame:
{
"name": "Home",
"route": "/",
"layout": "site.layout.iwc",
"title": "Design for small brands",
"sections": [
{
"type": "section",
"className": "band",
"layout": { "direction": "column", "gap": "16px" },
"elements": [
{ "type": "text", "variant": "title", "content": "Design for small brands" },
{ "type": "text", "className": "muted", "content": "Identity, packaging and websites for independent makers." },
{ "type": "button", "label": "See our work", "action": "navigate", "target": "/work" }
]
}
]
}{
"name": "Work",
"route": "/work",
"layout": "site.layout.iwc",
"title": "Work",
"sections": [
{
"type": "section",
"className": "band",
"layout": { "direction": "column", "gap": "24px" },
"elements": [
{ "type": "text", "variant": "h1", "content": "Selected work" },
{ "type": "section", "id": "branding", "elements": [{ "type": "text", "variant": "h2", "content": "Branding" }, { "type": "text", "content": "Logos, type and colour systems." }] },
{ "type": "section", "id": "websites", "elements": [{ "type": "text", "variant": "h2", "content": "Websites" }, { "type": "text", "content": "Fast, accessible sites built with IWC." }] }
]
}
]
}The 404 screen uses the layout too, and app.iwc points notFound at it:
{
"name": "Not found",
"route": "/404",
"layout": "site.layout.iwc",
"title": "Page not found",
"sections": [
{ "type": "section", "className": "band", "elements": [
{ "type": "text", "variant": "h1", "content": "This page doesn't exist" },
{ "type": "link", "label": "Go home", "target": "/" }
] }
]
}{
"name": "Lumen Studio",
"iwc": 1,
"theme": {
"tokens": {
"color-primary": "#5b4cf5",
"color-on-primary": "#ffffff",
"color-text": "#14141f",
"color-muted": "#5d6073",
"color-background": "#ffffff",
"color-surface": "#f6f4ff",
"color-border": "#e6e6ef",
"radius": "12px",
"font-body": "system-ui, sans-serif"
}
},
"styles": ["styles/main.css"],
"head": { "title": "%s · Lumen Studio" },
"notFound": "not-found.ui.iwc"
}Step 4: Style the frame
.site { min-height: 100vh; display: flex; flex-direction: column; }
.iwc-slot-content { flex: 1; }
.announcement { padding: 8px 16px; text-align: center; font-size: 14px; background: var(--color-primary); color: var(--color-on-primary); }
.site-header { padding: 14px 5vw; background: var(--color-background); border-bottom: 1px solid var(--color-border); }
.brand { font-weight: 800; color: inherit; text-decoration: none; }
.band { max-width: 960px; margin: 0 auto; padding: 72px 16px; }
.muted { color: var(--color-muted); }
.site-footer { padding: 24px 5vw; border-top: 1px solid var(--color-border); color: var(--color-muted); }
.footer-links { display: flex; gap: 16px; }
.footer-links a { color: inherit; }.site makes the frame fill the window, and .iwc-slot-content { flex: 1 } pushes the footer to the bottom of short pages.
Step 5: Test it
{
"name": "layout",
"cases": [
{ "name": "home is framed by the layout", "request": { "path": "/" }, "expect": { "status": 200, "contains": ["New: weekend workshops", "Lumen Studio", "Design for small brands", "© Lumen Studio"] } },
{ "name": "every page shares the same frame", "request": { "path": "/work" }, "expect": { "status": 200, "contains": ["New: weekend workshops", "Selected work", "© Lumen Studio"] } },
{ "name": "the current page is marked in the menu", "request": { "path": "/work" }, "expect": { "status": 200, "contains": ["aria-current=\"page\""] } },
{ "name": "unknown pages use the 404 screen inside the layout", "request": { "path": "/nope" }, "expect": { "status": 404, "contains": ["This page doesn", "© Lumen Studio"] } }
]
}iwc test ✓ layout › home is framed by the layout
✓ layout › every page shares the same frame
✓ layout › the current page is marked in the menu
✓ layout › unknown pages use the 404 screen inside the layout
4 passed, 0 failedThe 404 test checks "This page doesn" rather than the whole sentence because the apostrophe is HTML-escaped in the response.
Going further
- Several layouts: a project can have a site layout, a minimal one for sign-in pages and a dashboard layout with a sidebar; each screen picks one. Tutorial 10 builds a dashboard layout with a grid
template. - Sidebars on phones:
"mobile": "drawer"turns a slot into a slide-in drawer with a ☰ button.
Troubleshooting
| Message | Fix |
|---|---|
"layout" must be an object, got string | You put the layout file on an element. It belongs on the screen. |
| The footer floats mid-page on short pages | Make the layout root a flex column and give .iwc-slot-content flex: 1. |
Next: Build a form.
Source: apps/marketing/content/docs/tutorials/reusable-layout.md