hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Head Management
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
Server Caller
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
Styling
Composing Hono Middleware
WebSockets
Rooms & Presence
renderPage
Link Prefetch
Build & Deploy
Overview
Dialog
Popover
Tooltip
Menu
Context Menu
Select
Combobox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition
usePositioner
useDismiss
useFocusReturn

Styling#

Write CSS the way you'd write it for any app: design tokens, Tailwind, @font-face, component rules, all in one stylesheet. Hand the framework that stylesheet and it takes care of delivery: every route gets a <link> for the styles it actually uses, with nothing to hand-split and nothing to wire up yourself.

One global stylesheet#

Point css.global at your stylesheet in vite.config.ts:

// vite.config.ts
import { honoPreact } from 'hono-preact/vite';
import { cloudflareAdapter } from 'hono-preact/adapter-cloudflare';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    honoPreact({
      adapter: cloudflareAdapter(),
      css: { global: 'src/styles/root.css' },
    }),
  ],
});

The framework injects it for you, in dev and in production: no manual ?url import, no <link> in your Layout. Bring one stylesheet, and every page gets it.

At build time the framework goes further: it looks at which rules in that stylesheet only ever apply to one route's chunk of JavaScript, and splits those rules out into a sheet delivered just for that route, render-blocking in the SSR head, exactly like a route sheet you split by hand. What's left behind, the residual, ships as the global sheet every route still loads. Scoped rules load after the global sheet, so they win any equal-specificity tie against it, the same cascade order a hand-split sheet would give you. You don't request this or opt into it per route; it's automatic, and it never removes a rule from your app, only moves where it's delivered from.

OptionTypeDefaultDescription
globalstringrequiredProject-relative path to your global stylesheet, e.g. 'src/styles/root.css'.
autoSplitbooleantrueSplit route-exclusive rules into per-route sheets. Set false to deliver the stylesheet unsplit.
minSizenumber1024Minimum size in bytes for a route's split-out rules. Below this, they stay in the global sheet rather than costing a route an extra request.

css.global must point at a file that exists; the framework throws a clear build error if it doesn't, telling you the path it checked.

Head order is fixed: whatever your Layout or route renders into <head> itself (title, meta, any <link> you write by hand) comes first, then the global stylesheet, then route sheets. One consequence is worth knowing up front: because the global stylesheet lands later in the head than anything you hand-write there, it wins an equal-specificity tie against a stylesheet you link yourself. If you need to override a rule from the global stylesheet, the override belongs inside the global stylesheet itself (it's one file, so a later rule there wins the normal cascade way) or in a hand-split route sheet, which loads after it for the same reason.

How splitting decides what's exclusive#

A rule is scoped to a route only when every class it depends on shows up in exactly one route's built JavaScript, nowhere else. That's a strict bar on purpose: the framework would rather ship a rule as global than guess wrong and scope it to the wrong route.

Two kinds of classes never clear that bar, and both fail safe by staying global:

  • Classes built at runtime, like `item-${status}`, don't appear anywhere as a literal string, so the framework can't see them.
  • Classes that only appear in data, like a CSS class name stored in a CMS field or a database row, aren't in any route's JavaScript at all.

Either one alone is harmless: the rule just stays global, same as it always would have without splitting. The one case that can bite is a double failure: a route builds a class name at runtime, and that same class name happens to appear as a literal string in exactly one other route's code. Now the framework sees a single, unambiguous owner, and it isn't the route that actually needed the rule at runtime. If you hit this, the fix is to either hand-split that rule into a route sheet yourself (below), or turn off auto-split for the stylesheet with autoSplit: false.

Hand-split route sheets#

Auto-split covers rules the framework can prove are exclusive to one route. For anything else, or when you'd rather be explicit, import a stylesheet from a route module for its side effect and it ships only on that route:

// src/pages/home.tsx
import '@/styles/home.css';

export default function Home() {
  return <section class="hero">...</section>;
}

The same pattern on a layout module scopes the sheet to every route under that layout:

// src/components/DocsLayout.tsx
import '@/styles/docs.css';
import type { LayoutProps } from 'hono-preact';

export default function DocsLayout({ children }: LayoutProps) {
  return <div class="docs-shell">{children}</div>;
}

No ?url suffix and no manual <link>: a bare side-effect import is the whole contract. The framework follows each route's build-time import graph to find the CSS it pulls in, injects a <link rel="stylesheet"> for it after the global sheet (so it wins the same equal-specificity ties auto-split rules do), and carries it along on client-side navigation as part of the route's JS chunk.

Hand-split sheets and auto-split compose freely: nothing about importing home.css from home.tsx changes because root.css is also being split. Route sheets are always render-blocking, present for first paint with no flash of unstyled content, for the same reason the injected link is a real stylesheet link and not a hint.

Keep centering in one transform#

All framework CSS, whether it's your global stylesheet, an auto-split route sheet, or a hand-split one, goes through Lightning CSS as the production minifier, targeting the browsers Baseline Widely Available covers. One gotcha is worth knowing about ahead of time: when a rule declares a standalone translate / scale / rotate property alongside a transform property, the minifier keeps only transform and drops the standalone one. A rule that centers with translate: -50% -50% and animates with transform: scale(...) loses its centering in the production build, while vite dev (which doesn't run the minifier) still looks correct. Fold every component into a single transform so there's nothing for the minifier to drop.

/* Correct: translate and scale live in one transform, nothing is dropped */
.dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) scale(0.98);
}
/* Wrong: the minifier drops the standalone translate (a transform is
   present in the same rule), so only scale survives and centering is lost */
.dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  transform: scale(0.98);
}

The framework fills in two independent defaults, and configuring one in vite.config.ts leaves the other alone. build.cssMinify chooses the minifier (the framework defaults it to lightningcss), and css.lightningcss supplies the Baseline targets. So setting cssMinify: 'lightningcss' explicitly is a no-op that keeps the Baseline targets, and setting your own css.lightningcss keeps Lightning CSS as the minifier while your options win wholesale over the targets. Setting cssMinify: 'esbuild' swaps the minifier out of the Lightning CSS pipeline entirely, so the Baseline targets no longer apply to it.

Either way, that carve-out doesn't extend to auto-split output: once css.global and autoSplit are on, every residual and route sheet the splitter emits is re-serialized through Lightning CSS with minify: true directly (not via Vite's cssMinify setting), so a custom cssMinify never opts split output out of minification.

Preload critical fonts#

List your above-the-fold fonts on AppConfig.fonts as ?url imports, and the framework preloads them for you: a <link rel="preload" as="font" type="font/woff2" crossorigin> in the SSR head, plus a matching Link response header so a CDN or edge runtime can promote it to a 103 Early Hints response. Both flatten the font fetch into the same round trip as the HTML instead of waiting for the CSS that references it.

// src/app-config.ts
import { defineApp } from 'hono-preact';
import regular from '@/styles/fonts/brand-regular.woff2?url';
import semibold from '@/styles/fonts/brand-semibold.woff2?url';

export default defineApp({
  fonts: [regular, semibold],
});

crossorigin is mandatory on every font preload tag the framework emits: fonts always fetch in CORS mode, even when they're same-origin, so a preload without crossorigin doesn't match the font request the browser actually makes and the font downloads twice.

List only the weights your first paint needs; a body weight and a heading weight typically cover it. Preloading every weight in your type scale spends early bandwidth on fonts the current page never renders, competing with the resources that are actually on the critical path.

Font-display is a separate, per-stylesheet concern: AppConfig.fonts only controls when the browser starts fetching a font, not how it swaps in once loaded. Set font-display in your own @font-face rules as you normally would.