Theme Structure
Coil themes are customer-owned UI packages made of templates, published assets, and a small amount of manifest configuration.
The easiest way to understand the structure is to start with a real shape, then map each part back to the framework contract.
Start With A Working Shape
customer-app/
app.toml
templates/
layouts/
base.html
pages/
home.html
account.html
components/
nav.html
theme/
tokens.toml
assets/
site.css
site.js
logo.svg
What each part is doing:
templates/- owns HTML structure, layout shells, fragments, and page composition
theme/assets/- owns files that the asset pipeline publishes and templates reference through
asset(...)
- owns files that the asset pipeline publishes and templates reference through
theme/tokens.toml- optional customer convention for semantic design tokens
app.toml- tells Coil which template namespaces and asset roots belong to the active theme
The key point is that Coil does not treat the theme as “just CSS.” The theme is the customer app’s presentation boundary.
The Manifest Contract
The framework-supported theme configuration lives under [theme] in app.toml:
[theme]
active = "harbor"
template_namespaces = ["customer-app", "harbor"]
asset_roots = ["theme/assets"]
Annotated:
active- stable theme id for the customer app
template_namespaces- ordered namespace precedence for template lookup
asset_roots- relative directories that Coil publishes as theme assets
Field Reference
active
- Required: yes
- Type: string token
- Default: none
- Allowed values: any valid theme id token
Use it for:
- naming the active customer theme profile
Do not use it for:
- temporary folder aliases
- environment-specific behaviour
template_namespaces
- Required: yes
- Type: array of namespace tokens
- Default: none
- Constraints:
- must not be empty
- values must be unique
Use it for:
- deciding which templates win when multiple layers define the same template name
The practical rule is simple:
- put the customer-owned namespace first
- put lower-priority fallback namespaces later
asset_roots
- Required: no
- Type: array of relative paths
- Default: empty array
- Constraints:
- must be relative
- no absolute paths
- no
..traversal
Use it for:
- telling Coil which folders should be published and exposed through
asset(...)
For most apps, ["theme/assets"] is enough.
What Belongs In templates/ Versus theme/assets/
This is the split that developers most often blur.
Put it in templates/ when it is:
- HTML structure
- semantic landmarks
- page composition
- fragment composition
- render-model bindings
Put it in theme/assets/ when it is:
- CSS
- enhancement JS
- static images, icons, and fonts
If you find yourself putting route logic or data-fetching decisions into site.js, the split has
already broken down.
Dark, Light, And System Mode
Coil does not provide a framework-global dark-mode switch.
The current framework contract is narrower:
- Coil publishes templates and assets
- the customer app decides how theme mode works
- the result still has to remain accessible
The strongest checked-in pattern is a customer-owned control like this:
<div class="theme-switcher" role="group" aria-labelledby="theme-switcher-label">
<span class="sr-only" id="theme-switcher-label">Theme</span>
<button type="button" data-theme-option="light">Light</button>
<button type="button" data-theme-option="dark">Dark</button>
<button type="button" data-theme-option="system">System</button>
</div>
What this teaches:
- the server-rendered HTML already contains the control
- the enhancement script can persist the preference later
- semantics are visible in the template, not hidden in JS
Recommended theme-mode guidance:
- start from semantic CSS variables
- respect
prefers-color-scheme - keep explicit controls optional, not mandatory
- verify focus, contrast, and reduced motion in every mode
Multi-Site Themes
Most multi-site apps should keep one theme tree and branch on runtime values such as:
site.idsite.display_namesite.brand_namelocale
That is usually better than cloning the entire theme per site.
A good multi-site template looks like this:
<html xmlns:coil="https://coil.rs" coil:attr="lang=${locale}">
<body>
<a class="brand" coil:attr="href=${links.home}">
<span coil:text="${site.brand_name}">Brand</span>
</a>
</body>
</html>
This keeps one template tree while allowing site-aware branding and links.
Common Mistakes
Treating theme/ as only a CSS folder
The actual theme contract includes namespace precedence, published assets, and customer-owned shell behaviour.
Hardcoding final asset URLs
Templates should use logical asset paths such as asset('theme/assets/site.css'), not guessed CDN
paths.
Turning site.js into a second application runtime
Use enhancement JS to improve the HTML-first path, not replace it.
Assuming tokens.toml is a framework-mandated schema
It is useful and recommended, but today it is still a customer convention rather than a required runtime file.
Supporting Implementation And Repo Examples
Concrete supporting files:
apps/shoppr/app.tomlapps/shoppr/theme/assets/site.cssapps/shoppr/theme/assets/site.jsapps/shoppr/theme/tokens.tomlapps/gitly/app.tomlapps/gitly/theme/assets/site.cssapps/gitly/theme/assets/site.jscrates/coil-app/src/types/theme.rscrates/coil-app/src/manifest/document.rs