G · 07 guides

Guides / Styling

Styling

aihu styles components with @aihu/css-engine — a hard fork of Tailwind v4 re-targeted for Web Components. Instead of one global utility stylesheet, the engine scans your .aihu sources at build time and folds the utilities each component actually uses into that component's own scoped stylesheet.

There is no global utility sheet, no runtime CSS-in-JS, and for the static case nothing extra ships to the client.

See also: Utility Classes — the authoritative index of every supported class, variant, and brand token.

How it works

  • Build-time scanning. The engine reads SFC source, walks the compiler AST, collects the utility classes each component references, and emits a per-component stylesheet.
  • Scoped output, not global. Two components on the same page never share or leak utility rules — see Scoping for the two mechanisms.
  • Zero-bundle for the static case. Utilities resolve to plain CSS at compile time. The only runtime code is cn() and the progressive-feature fallbacks, and only if you import them.

Scoping

The rendering mode is a binary choiceshadowMode: 'light' | 'shadow' — and it decides how the emitted CSS is isolated. Both modes isolate; they just do it differently.

Shadow mode

The component's rules are folded into its shadow root's <style> alongside the authored @style block. The shadow boundary does the isolating. Design tokens still reach in, because custom properties inherit through the boundary — see Theming.

Only open shadow roots exist here. Open is the only mode aihu's composition and hydration can work with, which is why there is no 'closed' value.

Light mode

There is no boundary to hide behind, so the engine builds one out of CSS. Each component root is stamped with a generated data-a="<id>", and the component's rules are wrapped in:

governedcsscss75 B
@scope ([data-a="a1b2c3"]) to ([data-a]) {
  /* this component's rules */
}

The lower bound is the load-bearing half. data-a is stamped only on component roots, so to ([data-a]) makes the scope stop descending the moment it reaches the next component — a parent's styles cannot bleed into a child, even though both are in the same tree.

Three at-rules are hoisted out of the @scope block: @keyframes, @property, and @font-face. They are not scopable constructs — a @keyframes inside @scope simply does not register — so they are emitted at the top level. The practical consequence: a light-DOM component's rules are scoped, but its animation names are document-global. Name them distinctly.

Which mode you get

Precedence, highest first:

  1. a $shadow pin in the file
  2. the plugin-global shadowMode config
  3. the implicit default — 'light' for pages (@route) and layouts
  4. the leaf default — 'shadow'

Pages and layouts default to light so server-rendered content is reachable by non-JS crawlers. Leaves default to shadow.

Setting css: { shadowMode: 'light' } in viteAihuPlugin() puts everything in the light DOM — which is what this documentation site itself does, so its global token cascade reaches every component and SPA link interception works across the shell.

WC-native variants

On top of the standard Tailwind variant set, the engine adds variants that only make sense around a shadow root:

Variant Targets Example
host: the component's :host host:block
slotted: ::slotted(…) projected children slotted:text-sm
part-*: a named ::part(…) part-label:font-bold

Relational variants

group-: and peer-: style an element based on the state of a related element — an ancestor (group) or an earlier sibling (peer). Mark the related element with the bare group or peer class, then prefix the styled element's utilities.

Variant Relationship Compiles to
group-hover: ancestor marked group is hovered .group:hover .group-hover:<u>
group-focus: / -focus-visible: / -active: / -disabled: ancestor in that state .group:<state> …
peer-checked: earlier sibling marked peer is checked .peer:checked ~ …
peer-hover: / -focus: / -focus-visible: / -disabled: earlier sibling in that state .peer:<state> ~ …

The bare group / peer classes are markers — no styles of their own, they just anchor the relationship. Both elements must live in the same scope. peer only looks backward, since CSS has no forward sibling combinator, so the peer element must appear first in source order.

governedhtmlhtml165 B
<div class="group">
  <span class="group-hover:bg-primary">…</span>
</div>

<input class="peer" type="checkbox" />
<span class="peer-checked:bg-primary">…</span>

Variants stack left to right: md:group-hover:bg-primary wraps the relational rule in the md media query.

cn() — runtime class merging

Utilities resolve at build time, but a component that accepts a caller-supplied class has to merge at runtime. cn() does conflict-aware merging — last wins per utility group, so p-2 and p-6 collapse instead of both landing:

governedtsts96 B
import { cn } from '@aihu/css-engine/runtime/cn'

cn('rounded-lg p-2 bg-surface', userClassName)

This is the intended pairing for primitives, which ship zero CSS by design.

See also