Skip to content
ONIWC
iwc 0.1.0

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:

Project files
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.iwc

Step 1: The layout

Create layouts/site.layout.iwc:

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" }
  }
}
PropertyMeaning
slotsThe 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
structurePer-slot behaviour: position: "sticky" keeps the header in view; mobile: "hidden" hides the announcement on phones
classNameA 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.

components/site-header.component.iwc
{
  "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".

components/site-footer.component.iwc
{
  "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:

ui/home.ui.iwc
{
  "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" }
      ]
    }
  ]
}
ui/work.ui.iwc
{
  "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:

ui/not-found.ui.iwc
{
  "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": "/" }
    ] }
  ]
}
app.iwc
{
  "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

styles/main.css
.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

tests/layout.test.iwc
{
  "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"] } }
  ]
}
Terminal
iwc test
Output
  ✓ 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 failed

The 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

MessageFix
"layout" must be an object, got stringYou put the layout file on an element. It belongs on the screen.
The footer floats mid-page on short pagesMake 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