Documentation
Styling
Co-located CSS, collected by the build, with no import and no link tag to maintain.
Put a stylesheet next to the code it styles. The build finds it, bundles every sheet into one content-hashed file, and injects the <link> into <head> for you.
routes/index.tsx lib/Card.tsx islands/Counter.tsx
routes/index.css lib/Card.css islands/Counter.css
│ │ │
└────────────────────┴─────────────────────┘
│
styles-4kq2n7wd.css one file, hashed
│
<link> injected into <head>There is nothing to import and nothing to remember. Deleting a component deletes its styles with it, because the two live in the same folder and the build stops finding one when you remove the other.
Membership is by location, not by import
This is the part worth understanding, because it is not how most bundlers work. Routes and lib/ are server modules the bundler never sees — an import "./Card.css" there resolves to a path string at runtime and would never reach a stylesheet. Scanning three directories gives one rule everywhere instead of a different rule per directory.
Files are sorted before bundling, so the cascade is deterministic and the content hash changes only when the CSS does. Two builds of the same source produce the same filename, which is what makes the immutable cache header on it safe.
The three scanned directories are routes/, islands/ and lib/. Anything under public/ is still served as-is at the URL root, which remains the right place for a stylesheet you want at a fixed, unhashed URL.
Which files are picked up
routes/**/*.css collected and bundled islands/**/*.css collected and bundled lib/**/*.css collected and bundled public/**/*.css NOT bundled - served as-is at its own URL anywhere else not found at all
Recursive, so lib/styles/tokens.css and islands/nav/menu.css are both found. Three directories and no configuration: if a stylesheet is not being applied, it is almost always sitting outside all three.
public/ is the deliberate exception. Everything there is served byte-for-byte at its own URL, so a stylesheet you want to link yourself — a vendor file, a print sheet, something a third party fetches — goes there and gets its own <link>. It is never merged into the bundle, and it is never content-hashed, so it revalidates on each deploy instead of being cached forever.
The order sheets are concatenated
One bundle means one cascade, and the order is fixed rather than incidental. Sheets are sorted by their full path, which produces this:
1. islands/** islands/Counter.css, islands/nav/Menu.css
2. lib/** lib/styles/00-tokens.css, lib/ui/Card.css
3. routes/** routes/about.css, routes/index.css
and within each, alphabetically:
lib/styles/00-tokens.css before lib/styles/90-print.cssThe directory order falls out of the same sort — "islands" sorts before "lib" sorts before "routes" — so it is stable, but it is alphabetical rather than designed. Do not rely on a routes/ sheet overriding a lib/ one by position; use specificity, or a numeric prefix, and the intent stays readable.
lib/styles/00-tokens.css # custom properties, @font-face
lib/styles/10-base.css # element defaults, resets
lib/styles/20-layout.css # containers, grid
lib/styles/40-components.css # buttons, cards
lib/styles/90-utilities.css # last word, highest specificitySorting is what keeps the content hash stable. An unsorted directory scan returns files in whatever order the filesystem gives, so the same sources would produce different bytes on different machines and the hash would churn on every build.
What production does to it
stoneware dev stoneware build
────────────────────────── ──────────────────────────
concatenated, readable, minified
with a comment naming
each source file styles-4kq2n7wd.css
hashed by content
rebuilt when a .css
under routes/, islands/ Cache-Control: immutable,
or lib/ changes max-age=31536000The hash is the whole caching strategy. Because the filename changes whenever the bytes change, the file can be cached for a year and a deploy still takes effect immediately — the page simply asks for a different filename. Nothing has to be purged and no cache header has to be tuned.
Minification is Bun's own CSS minifier, on in production and off in development. Development keeps the file readable, with a comment above each section naming the file it came from, so a rule you cannot place is one Ctrl-F away from its source.
Style objects inside an island
Islands can set style from a value, because an island genuinely re-renders when its signals change. An object is serialized the way you would expect, and a number gets px unless the property is one that takes a bare number.
This works under the strict default policy, and the reason is worth knowing: after hydration the client writes styles through the CSSOM — element.style.setProperty — which a Content-Security-Policy does not govern. It is the style attribute in HTML that style-src blocks, not the DOM property.
server-rendered HTML style="width:40%"
a strict CSP refuses to apply this
after hydration element.style.setProperty(...)
applied, and updated on every changeSo the initial paint is the one place to be careful. Give the element a class that looks right on its own and let the style object carry only what changes — otherwise the first frame is unstyled on a strict policy and correct a moment later. Development warns when a style attribute is emitted under a policy that will not run it, naming the element.
import { signal } from "stoneware/signals";
const pct = signal(40);
export default function Meter() {
return (
<div class="meter">
<div class="meter-fill" style={{ width: `${pct.value}%`, opacity: 0.8 }} />
</div>
);
} { backgroundColor: "red" } -> background-color:red
{ marginTop: 8 } -> margin-top:8px
{ opacity: 0.8 } -> opacity:0.8 (unitless)
{ zIndex: 3 } -> z-index:3 (unitless)
{ "--brand": "#639" } -> --brand:#639 (passed through)
{ color: null } -> omitted entirelyUnitless properties are the ones where a bare number is already valid CSS: opacity, z-index, flex and its parts, order, line-height, font-weight, zoom, grid-row and grid-column. Everything else numeric gets px, and 0 stays 0.
This is for values that change, not for styling in general. A style attribute is the highest-specificity thing on the page and it is invisible to your stylesheet — reach for it when the number is computed, and for a class when it is not.
The style attribute does not work here
The renderer accepts style={{ color: "red" }} and serialises it correctly. The browser will then refuse to apply it, because the default policy sets style-src without unsafe-inline — and that governs style attributes, not only <style> blocks. The element is there, the declaration is in the HTML, and nothing happens.
style={{ color: "red" }} emitted, then ignored by the browser
class="note" works, is cacheable, and lives beside the
+ note.css component the build collects it fromDevelopment warns when it sees one, naming the element and the fix. It stays silent if you have widened the policy or set csp: false, because then nothing is being blocked. The attribute is still emitted either way — a diagnostic that rewrote your markup would be worse than the problem.
An island that genuinely needs to drive a value at runtime — a progress bar, a scroll gauge — sets a CSS custom property through the CSSOM instead. CSP does not govern that, and the value stays in a stylesheet where it belongs.
Why not CSS Modules
Bun supports CSS Modules in the bundler, but its runtime returns the file path rather than the generated class map. An island is rendered in both places — once on the server for the initial HTML, once in the browser on hydration — so the two would disagree about what a class is called, and the markup would not match the stylesheet.
import styles from "./Counter.module.css";
styles.button // bundler: "Counter_button_a1b2c3"
// runtime: undefined — the import is a path stringRather than ship scoping that works in one half of a render and silently fails in the other, v0.1 does not offer it. Scoping is naming discipline for now — a prefix per component is enough at this size, and real scoping can arrive later without changing where files live.
Something wrong in the framework itself rather than the page? Open an issue on GitHub.