Skip to content

Styling

WebJs ships two styling models and lets you pick per component. The default is light DOM with Tailwind CSS: a static compiled stylesheet (so it works with JavaScript off) with @theme design tokens. Shadow DOM is opt-in when you need truly scoped styles or third-party-embed isolation. <slot> projection works identically in both modes (light DOM uses framework projection), so slot usage is not a reason to opt into shadow.

The default: light DOM + Tailwind

Pages, layouts, and components render into the normal document tree. Tailwind utility classes apply directly: no :host, no ::part, no CSS-variable plumbing. Design tokens live in a single @theme block in public/input.css, which css:build compiles to a static public/tailwind.css the layout links, so the app is fully styled with JavaScript disabled (a real stylesheet, not an in-browser compile). The token VALUES stay inline in the layout as plain CSS custom properties, so they resolve with JS off too.

In dev the scaffold keeps that stylesheet fresh with an on-request recompile (a webjs.dev.regenerate rule), not a background tailwindcss --watch. When a source changes, the dev server recompiles public/tailwind.css before serving it, so a newly added utility class is never rendered unstyled and there is no watch process that can die or lag. Prod builds the same file once before serving, so dev and prod share the identical compile.

// public/input.css (compiled to a static public/tailwind.css by css:build,
// which the dev / start tasks run automatically). The @theme maps live here.
@import "tailwindcss";
@theme {
  --color-background:       var(--background);
  --color-foreground:       var(--foreground);
  --color-primary:          var(--primary);
  --color-muted-foreground: var(--muted-foreground);
  --font-serif:             var(--font-serif);
  --text-display:           clamp(2.6rem, 1.6rem + 3.2vw, 4.25rem);
  --duration-fast:          140ms;
}

// app/layout.ts excerpt
import { html } from '@webjsdev/core';

export default function RootLayout({ children }: { children: unknown }) {
  return html`
    <link rel="stylesheet" href="/public/tailwind.css">
    <style>
      /* Token VALUES: plain CSS custom properties, so they resolve with JS off. */
      :root {
        --background: oklch(0.14 0.01 55);
        --foreground: oklch(0.96 0.015 60);
        --primary:    oklch(0.78 0.14 55);
        /* …etc */
      }
    </style>
    <main class="max-w-[760px] mx-auto px-4 py-12">
      ${children}
    </main>
  `;
}

From any page or component you now write things like:

<h1 class="font-serif text-display text-foreground mb-6">Hello</h1>
<p class="text-muted-foreground font-sans">Lede copy</p>
<a class="text-primary hover:underline duration-fast">Link</a>

Light-DOM components

Light DOM is the default for any WebComponent. Tailwind classes apply as they would on plain HTML:

import { WebComponent, html, signal } from '@webjsdev/core';

export class Counter extends WebComponent {
  // static shadow = false is the default, no need to declare it.
  // Instance signal carries component-local state. SignalWatcher
  // (built into WebComponent) auto-tracks .get() reads.
  count = signal(0);

  render() {
    return html`
      <div class="inline-flex items-center gap-2 font-mono">
        <button class="px-3 py-1 rounded border border-border" @click=${() => this.count.set(this.count.get() - 1)}>−</button>
        <output class="min-w-[2ch] text-center">${this.count.get()}</output>
        <button class="px-3 py-1 rounded border border-border" @click=${() => this.count.set(this.count.get() + 1)}>+</button>
      </div>
    `;
  }
}
Counter.register('my-counter');

Class-prefix rule for light-DOM custom CSS

Tailwind utilities are unique by construction, so most light-DOM components need zero custom CSS. But when you do reach for a <style> block or an imported stylesheet, every class selector MUST be prefixed with the component's tag name. Otherwise two components that both define .card or .header will style each other.

// Pattern A: BEM-ish class names prefixed with tag
class MyCard extends WebComponent {
  render() {
    return html`
      <style>
        .my-card__body  { padding: 16px; }
        .my-card__title { font-weight: 600; }
      </style>
      <div class="my-card__body">
        <h3 class="my-card__title">${title}</h3>
      </div>
    `;
  }
}

// Pattern B: descendant selector rooted at the tag
class MyCard extends WebComponent {
  render() {
    return html`
      <style>
        my-card .body  { padding: 16px; }
        my-card .title { font-weight: 600; }
      </style>
      <div class="body"><h3 class="title">${title}</h3></div>
    `;
  }
}

Pick one pattern and stay consistent across a component.

Layout: block hosts and even grids (the CSS traps)

Two layout defects ship silently because the checker and the type-checker are static (they never render the pixels), so both only show once you render and interact. Both have a one-line fix.

Light-DOM component hosts are display: block by default. A custom element is display: inline in plain CSS, which would collapse a light-DOM component used as a block container (a board, a card, a panel) to its content size. The framework marks every light host data-wj-host and injects one rule in a low-priority cascade layer, @layer webjs-host { :where([data-wj-host]) { display: block } }, so a container fills its parent. The layer keeps it overridable by any author style, including Tailwind utilities (class="flex", grid, hidden) whose layer wins; a [hidden] carve-out keeps ?hidden working. Want an inline light component? Opt out with my-badge { display: inline }.

Shadow-DOM hosts are NOT marked. A document rule targeting the host would override the shadow tree's own :host display, so the framework leaves shadow hosts alone. A shadow-DOM component sets its host display the idiomatic way, :host { display: block } (or flex / grid) in static styles, which is fully respected. Set it for a shadow block container (an unstyled shadow host stays display: inline).

Size the HOST, not just an inner wrapper. The host custom element is the box the parent lays out. display: block stops the inline-collapse, but a host that is a flex/grid item in a centering parent (flex justify-center, grid place-items-center) is still sized to its content unless it carries width itself. Put w-full max-w-[...] on the host, not only on an inner <div> (an inner w-full resolves against a collapsed host and the whole component shrinks). Symptom: a board or card renders tiny even though its inner grid says w-full max-w-[400px].

An even grid uses 1fr tracks, never auto rows. The reflow bug (a cell grows when it gets content while the others shrink) comes from auto-sized rows. Put aspect-ratio on the CONTAINER, size the tracks explicitly, and cap the cells:

<!-- a 3x3 board whose cells stay equal and square as it fills -->
<div class="grid gap-2 aspect-square [grid-template-columns:repeat(3,1fr)] [grid-template-rows:repeat(3,1fr)]">
  ${cells.map((c) => html`
    <button class="grid place-items-center min-h-0 overflow-hidden text-[clamp(1rem,8cqi,3rem)]">${c}</button>
  `)}
</div>
  • aspect-square on the CONTAINER plus repeat(N,1fr) columns AND rows makes every cell an equal square that does not resize as marks land. Putting aspect-square on the CELLS is the common mistake that produces uneven rows.
  • min-h-0 + overflow-hidden on a cell stops its content from forcing the track taller (a grid child's implicit min-height: auto otherwise lets content push past its track).
  • Size text relative to the cell (clamp(), container-query units cqi) so the glyph scales with the board, not the reverse.

Verify by USING it. A layout bug only shows mid-interaction. Render the app, play through every state (fill the board, win, draw, reload), and confirm nothing resizes and the cells stay equal.

Opting in to shadow DOM

Set static shadow = true when you want adoptedStyleSheets-scoped styles, real <slot> projection, or third-party-embed-proof CSS isolation:

import { WebComponent, html, css } from '@webjsdev/core';

export class Card extends WebComponent {
  static shadow = true;                  // opt in
  static styles = css`
    :host { display: block; padding: 16px; border: 1px solid var(--border); border-radius: 8px; }
    h3 { margin: 0 0 8px; }
    p  { color: var(--muted-foreground); margin: 0; }
  `;
  render() {
    return html`
      <h3><slot name="title"></slot></h3>
      <p><slot></slot></p>
    `;
  }
}
Card.register('my-card');

Shadow-DOM components are SSR'd via Declarative Shadow DOM. Styles paint before JS loads, no hydration runtime, and the browser enforces the boundary. Light-DOM components are SSR'd as direct HTML with a <!--webjs-hydrate--> marker, and client-side rendering replaces the marker without flash.

Design tokens via CSS custom properties

CSS custom properties inherit through shadow DOM boundaries. Define them once on :root (as the blog example does in its layout) and both light-DOM and shadow-DOM components can consume them via Tailwind classes (text-foreground, bg-card) or bare CSS (var(--foreground)).

Gotcha: an animated @property paints its var() fallback inside a link (Chromium)

An element that paints a registered @property custom property directly, for example background: var(--brand-cycle, <fallback>), paints the fallback instead of the live animated value whenever it has an <a href> ancestor, when --brand-cycle is a registered @property animated by @keyframes on :root. A registered @property always has a valid computed value, so it must never paint its var() fallback; that it does here is a Chromium paint bug (confirmed headless and headed; other engines are unaffected).

The tell that it is a PAINT bug, not a style bug: getComputedStyle returns the correct live animated value, only the painted pixels are stale. So verify with a screenshot or pixel sample, never with getComputedStyle (it lies here). The trigger is specifically an <a> with an href (a real link). The same subtree under a <div>, a <button>, or an <a> with no href animates correctly, which points at Chromium's visited-link paint isolation (links paint through a separate path, for :visited history-sniffing defense, that does not invalidate on a registered-custom-property animation). Neither isolation: isolate, will-change, content-visibility, a self-animation, nor re-declaring the property fixes it.

The fix is to route the value through color + currentColor instead of a direct var() paint reference. Chromium's link paint path does invalidate color, so this sidesteps the bug with no JavaScript:

/* BROKEN inside <a href>: paints the fallback, not the animated color */
.wordmark { background: var(--brand-cycle, #ebeff2); }

/* WORKS: the animated property rides color, the paint reads currentColor */
.logo-link { color: var(--brand-cycle, #ebeff2); }   /* the <a> (or the element) */
.wordmark  { background: currentColor; }             /* the painted descendant */

DRY'ing up repeated Tailwind classes via JS helpers

When the same bundle of Tailwind classes appears in 2+ places, extract it into a JS helper in lib/utils/ui.ts. The helper runs at SSR time inside html``, so the browser sees fully materialised HTML. No client-side runtime, no diff from inline classes.

// lib/utils/ui.ts
import { html } from '@webjsdev/core';

/** `label` kicker: small caps, accent colour, above headings. */
export function rubric(label: string) {
  return html`
    <span class="block font-mono text-[11px] leading-none font-semibold tracking-[0.2em] uppercase text-primary mb-4">● ${label}</span>
  `;
}

/** "← label" back link. */
export function backLink(href: string, label: string) {
  return html`
    <a href=${href} class="inline-block mb-12 text-muted-foreground/70 no-underline font-mono text-[11px] uppercase tracking-[0.15em] duration-fast hover:text-foreground">← ${label}</a>
  `;
}

Consume anywhere:

// app/blog/[slug]/page.ts
import { rubric, backLink } from '#lib/utils/ui.ts';

export default function Post({ params }) {
  return html`
    ${backLink('/', 'Posts')}
    ${rubric('post')}
    <h1 class="font-serif text-display text-foreground">Hello</h1>
  `;
}

When to extract. Inline classes when they appear once. Extract when they repeat 2+ times identically, or vary only by 1–2 props (e.g. a margin size). Don't force-fit. Radically different call sites should stay inline.

Why not @apply? @apply hides which utilities a class uses and creates a second source of truth. JS helpers keep the class bundle visible at the definition site and compose naturally with conditional classes and active states.

Global styles and pseudo-elements

Some CSS can't be expressed as utility classes: body defaults, ::selection, ::-webkit-scrollbar, body::before decorative overlays. Put these in a plain <style> block in the root layout:

// app/layout.ts excerpt
<style>
  html, body { margin: 0; }
  body {
    background: var(--background);
    color: var(--foreground);
    font: 16px/1.65 var(--font-sans);
  }
  ::selection { background: var(--primary-tint); }
  ::-webkit-scrollbar { width: 10px; }
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; }
</style>

Dark mode

  1. Define dark tokens in :root { ... } as the default.
  2. Override for light via :root[data-theme='light'] and @media (prefers-color-scheme: light) { :root:not([data-theme='dark']) { ... } }.
  3. Ship a <theme-toggle> component that sets data-theme on <html> + persists to localStorage.
  4. Add a synchronous <script> before your <style> block that reads localStorage and sets data-theme before any paint. No FOUC.

Vanilla CSS end-to-end (opt out of Tailwind)

Tailwind is the default but not a requirement. If you prefer hand-written CSS everywhere, drop the <link> to public/tailwind.css (and public/input.css + the css:build script) and follow the wrapper-scoping convention below so generic class names (.btn, .input, .header) can't collide across pages, layouts, and components in the global light-DOM namespace.

Three scopes, one rule each

ScopeWrapper selectorDerived from
ComponentCustom-element tagAlready unique via customElements.define
Page.page-<route>app/dashboard/page.ts.page-dashboard. app/blog/[slug]/page.ts.page-blog-slug. Route groups (marketing) drop. Root app/page.ts.page-home.
Layout.layout-<name>app/layout.ts.layout-root. app/admin/layout.ts.layout-admin.

Every page wraps its output in <div class="page-<route>">. Every layout wraps in <div class="layout-<name>">. Components scope via their tag name. Styles colocate with the markup as const STYLES = css`…` and interpolate via <style>${STYLES.text}</style>. The standalone @webjsdev/intellisense (and the webjs editor extension) resolves class go-to-definition inside those blocks.

Page scope

// app/dashboard/page.ts
import { html, css } from '@webjsdev/core';

const STYLES = css`
  .page-dashboard {
    .actions     { display: flex; gap: 12px; }
    .btn         { padding: 12px 24px; border-radius: 999px; }
    .btn-primary { background: var(--primary); color: var(--primary-foreground); }
  }
`;

export default function Dashboard() {
  return html`
    <style>${STYLES.text}</style>
    <div class="page-dashboard">
      <div class="actions">
        <a class="btn btn-primary" href="/new">+ New</a>
      </div>
    </div>
  `;
}

Layout scope

// app/layout.ts
import { html, css } from '@webjsdev/core';

const STYLES = css`
  .layout-root {
    .header { position: fixed; inset-inline: 0; top: 0; } /* fixed, NOT sticky (see note) */
    .nav    { display: flex; gap: 16px; }
  }
`;

export default function RootLayout({ children }: { children: unknown }) {
  return html`
    <style>${STYLES.text}</style>
    <div class="layout-root">
      <header class="header">
        <nav class="nav">…</nav>
      </header>
      <main>${children}</main>
    </div>
  `;
}

Pin a header with position: fixed, never position: sticky. A sticky header flickers its background for one frame on iOS WebKit (every iOS browser) during a client-router navigation: the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug, and the GPU-promotion hacks (translateZ, will-change) do not fix it. Use position: fixed and reserve the header height on the content with a --header-height CSS variable (kept exact by a ResizeObserver). It is iOS-only and invisible on desktop, Android, and in DevTools emulation, so it shows only on a real device.

Component scope

// components/my-card.ts
import { WebComponent, html, css } from '@webjsdev/core';

const STYLES = css`
  my-card {
    .body  { padding: 16px; border: 1px solid var(--border); }
    .title { font-weight: 600; }
  }
`;

export class MyCard extends WebComponent {
  render() {
    return html`
      <style>${STYLES.text}</style>
      <div class="body">
        <h3 class="title">${this.title}</h3>
      </div>
    `;
  }
}
MyCard.register('my-card');

Primitives stay intentionally global

A small curated set of design-system classes (rubric, banner, accent-link, display-h1, code-chip, …) lives once in the root layout and is intentionally global. These are your design system, treated the way Bootstrap treats .btn. Everything else is scoped.

Tradeoffs vs Tailwind

  • More per-file CSS to write: no utility ecosystem.
  • Wrapper discipline: every page and every layout remembers to wrap.
  • Rename cost: moving app/dashboard/app/admin/ is 2 textual edits in one file: the .page-dashboard selector in the css`…` block and the matching class="page-dashboard" on the wrapper div.

You get in return: no browser-runtime script, no @theme block, idiomatic CSS you can debug with plain DevTools, and a cascade that works exactly the way you read it.

Pick one styling convention per project and stay consistent. The default is Tailwind. The scoped-wrapper convention above is the supported alternative when you want plain CSS end-to-end.

How SSR works for each mode

  • Light DOM: component content is serialised as direct children of the custom element with a leading <!--webjs-hydrate--> marker. Global stylesheets paint immediately. On connect the client renderer replaces the marker with rendered content (identical output for unchanged state, no flash).
  • Shadow DOM: component content is serialised inside a <template shadowrootmode="open">. The browser attaches the shadow root automatically, so styles paint before any JS loads. On connect the component upgrades and adopts the same stylesheet via adoptedStyleSheets, so SSR and client styles stay in sync.