Skip to main content
doc0blade47/honodocumentation home
Sign in

CSS Styling

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Hono's CSS styling utilities provide a unified, zero-dependency mechanism for authoring, scoping, hashing, and injecting styles across both server-side template rendering and client-side browser runtimes. By bridging server-side context tracking with dynamic CSSOM rule insertion in hono/jsx/dom, the CSS helper enables template literal-based style declarations (css, cx, keyframes, and viewTransition) that automatically normalize identifiers, minify CSS strings, and manage critical style injection without external build steps.

Sources: src/jsx/dom/css.ts:1-143, src/helper/css/common.ts:1-178, src/helper/css/index.ts:1-194

CSS Helper Public API

Core Functions and Signatures

The public API surface for Hono's CSS styling utilities is exported from src/helper/css/index.ts and src/helper/css/common.ts. It exposes functions like css, cx, keyframes, viewTransition, and rawCssString, alongside factory functions such as createCssContext for customizing identifiers and context behavior across both server and browser environments.

The styling helper functions accept template strings and variable values, producing hashed class name objects, view transitions, or keyframe identifiers. The createCssContext function returns an object containing these bound helpers along with a Style component for rendering stylesheet elements with optional nonces.

FunctionSignatureDescription
css(strings: TemplateStringsArray, ...values: CssVariableType[]): Promise<string>Generates a scoped hashed class name and registers the corresponding CSS rule.
cx(...args: (CssClassName | Promise<string> | string | boolean | null | undefined)[]): Promise<string>Combines multiple class names or conditional style arguments into a single string.
keyframes(strings: TemplateStringsArray, ...values: CssVariableType[]): CssClassNameCommonDefines scoped @keyframes animation names and styles.
viewTransition(strings: TemplateStringsArray, ...values: CssVariableType[]): Promise<string>Creates view transition styles or helper wrappers.
rawCssString(value: string): CssEscapedStringWraps raw CSS text so it is inserted without escaping or modifications.

Sources: src/helper/css/common.ts:32-36, src/helper/css/index.ts:38-60, src/helper/css/index.ts:71-193

export interface CssClassName {
  [SELECTOR]: string
  [CLASS_NAME]: string
  [STYLE_STRING]: string
  [SELECTORS]: CssClassName[]
  [EXTERNAL_CLASS_NAMES]: string[]
}

Sources: src/helper/css/common.ts:13-19

Note

createCssContext is an experimental feature whose API configuration parameters and return types may change in future releases. It accepts an options object containing an id, optional classNameSlug transformer, and onInvalidSlug callback.

Sources: src/helper/css/index.ts:62-79

Class Name Hashing and Normalization

Deterministic Style Hashing and Minification

Hono generates deterministic class name hashes using an internal toHash function adapted from goober. This algorithm processes the input style string character by character, computing an integer accumulator via multiplication and character code addition (str.charCodeAt(i++)), and prepending the css- prefix to produce identifiers like css-1234567890.

const toHash = (str: string): string => {
  let i = 0,
    out = 11
  while (i < str.length) {
    out = (101 * out + str.charCodeAt(i++)) >>> 0
  }
  return 'css-' + out
}

Sources: src/helper/css/common.ts:43-50

To keep emitted style rules compact, the minify function strips redundant whitespace, comments, and unnecessary separators using regular expressions matched via minifyCssRe. It preserves quoted strings defined by double or single quotes while collapsing spaces, head/tail whitespace, multi-line/single-line comments, and surrounding whitespace around structural tokens like {, }, :, ,, and ;.

const cssStringReStr: string = [
  '"(?:(?:\\\\[\\s\\S]|[^"\\\\])*)"', // double quoted string
 
  "'(?:(?:\\\\[\\s\\S]|[^'\\\\])*)'", // single quoted string
].join('|')
const minifyCssRe: RegExp = new RegExp(
  [
    '(' + cssStringReStr + ')', // $1: quoted string
 
    '(?:' +
      [
        '^\\s+', // head whitespace
        '\\/\\*.*?\\*\\/\\s*', // multi-line comment
        '\\/\\/.*\\n\\s*', // single-line comment
        '\\s+$', // tail whitespace
      ].join('|') +
      ')',
 
    '\\s*;\\s*(}|$)\\s*', // $2: trailing semicolon
    '\\s*([{};:,])\\s*', // $3: whitespace around { } : , ;
    '(\\s)\\s+', // $4: 2+ spaces
  ].join('|'),
  'g'
)
 
export const minify = (css: string): string => {
  return css.replace(minifyCssRe, (_, $1, $2, $3, $4) => $1 || $2 || $3 || $4 || '')
}

Sources: src/helper/css/common.ts:75-102

Label Extraction and Identifier Validation

Template literal styles can include an optional leading comment label (e.g., /* my-button */) to aid readability and custom class name generation. The buildStyleString helper extracts this label using a regular expression match on strings[0]. The normalizeLabel function then trims leading/trailing whitespace and replaces inner whitespace sequences with hyphens.

const normalizeLabel = (label: string): string => {
  return label.trim().replace(/\s+/g, '-')
}

Sources: src/helper/css/common.ts:52-54, src/helper/css/common.ts:142-142

Custom class names generated via ClassNameSlug callbacks are validated against CSS identifier rules before use. The isValidClassName validator ensures names match standard identifier syntax, while isValidKeyframeName additionally checks that animation names do not collide with CSS-wide reserved keywords.

Validator FunctionRegex / RuleDescription
isValidClassName/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/Validates that a string is a safe CSS identifier.
isValidKeyframeNameisValidClassName(name) && !RESERVED_KEYFRAME_NAMES.has(...)Validates class names and checks against reserved animation keywords.
const isValidClassName = (name: string): boolean => /^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(name)
 
// CSS-wide keywords that are invalid as @keyframes names per the spec
const RESERVED_KEYFRAME_NAMES = new Set([
  'default',
  'inherit',
  'initial',
  'none',
  'revert',
  'revert-layer',
  'unset',
])
const isValidKeyframeName = (name: string): boolean =>
  isValidClassName(name) && !RESERVED_KEYFRAME_NAMES.has(name.toLowerCase())

Sources: src/helper/css/common.ts:56-69

Warning

If a custom ClassNameSlug returns an invalid slug that fails isValidClassName or contains a reserved keyframe name, Hono invokes onInvalidSlug (which defaults to console.warn) and falls back to the default hash-based class name.

Sources: src/helper/css/common.ts:71-73

Server Critical CSS Style Component

Context Creation and State Tracking

The createCssContext factory initializes request-scoped tracking maps and generators needed during server-side template rendering. It maintains a WeakMap named contextMap to store used class name data per render context, alongside a nonceMap tracking Content Security Policy nonces.

export const createCssContext = ({
  id,
  classNameSlug,
  onInvalidSlug,
}: {
  id: Readonly<string>
  classNameSlug?: ClassNameSlug
  onInvalidSlug?: OnInvalidSlug
}): DefaultContextType => {
  const [cssJsxDomObject, StyleRenderToDom] = createCssJsxDomObjects({ id })
 
  const contextMap: WeakMap<object, usedClassNameData> = new WeakMap()
  const nonceMap: WeakMap<object, string | undefined> = new WeakMap()
 

Sources: src/helper/css/index.ts:71-84

When a style is referenced during JSX rendering, addClassNameToContext checks whether the render context is registered in contextMap. If absent, it initializes a tuple containing two records: toAdd (class names queued for emission) and added (class names already output). It inspects the primary selector and nested sub-selectors, queuing unrendered definitions and attaching an asynchronous HTML escaped callback to handle final insertion.

const addClassNameToContext: HtmlEscapedCallback = ({ context }) => {
  if (!contextMap.has(context)) {
    contextMap.set(context, [{}, {}])
  }
  const [toAdd, added] = contextMap.get(context) as usedClassNameData
  let allAdded = true
  if (!added[cssClassName[SELECTOR]]) {
    allAdded = false
    toAdd[cssClassName[SELECTOR]] = cssClassName[STYLE_STRING]
  }
  cssClassName[SELECTORS].forEach(
    ({ [CLASS_NAME]: className, [STYLE_STRING]: styleString }) => {
      if (!added[className]) {
        allAdded = false
        toAdd[className] = styleString
      }
    }
  )
  if (allAdded) {
    return
  }
 
  return Promise.resolve(raw('', [appendStyle]))
}

Sources: src/helper/css/index.ts:123-146

Note

usedClassNameData is defined as a tuple type [Record<string, string>, Record<string, true>], cleanly separating active styles awaiting buffer flushing from global lookup records tracking already injected rules.

Sources: src/helper/css/index.ts:33-36

Style Emission and DOM Insertion

The Style component outputs the initial <style> element using Hono's raw helper, configured with a designated element identifier and an optional cryptographic nonce. Its callback registers the nonce against the active render context.

const Style: StyleType = ({ children, nonce } = {}) =>
  raw(
    `<style id="${id}"${nonce ? ` nonce="${nonce}"` : ''}>${
      children ? (children as unknown as CssClassName)[STYLE_STRING] : ''
    }</style>`,
    [
      ({ context }) => {
        nonceMap.set(context, nonce)
        return undefined
      },
    ]
  )

Sources: src/helper/css/index.ts:182-193

During HTML buffering and serialization, appendStyle iterates over accumulated class names, marking each as added. It formats pseudo-global selectors directly or wraps standard class names in period prefixes and curly braces. It then checks if the output buffer already contains an inline style element matching the configured identifier using replaceStyleRe.

const replaceStyleRe = new RegExp(`(<style id="${id}"(?: nonce="[^"]*")?>.*?)(</style>)`)
 
const newCssClassNameObject = (cssClassName: CssClassNameCommon): Promise<string> => {
  const appendStyle: HtmlEscapedCallback = ({ buffer, context }): Promise<string> | undefined => {
    const [toAdd, added] = contextMap.get(context) as usedClassNameData
    const names = Object.keys(toAdd)
 
    if (!names.length) {
      return
    }
 
    let stylesStr = ''
    names.forEach((className) => {
      added[className] = true
      stylesStr += className.startsWith(PSEUDO_GLOBAL_SELECTOR)
        ? toAdd[className]
        : `${className[0] === '@' ? '' : '.'}${className}{${toAdd[className]}}`
    })
    contextMap.set(context, [{}, added])
 
    if (buffer && replaceStyleRe.test(buffer[0])) {
      buffer[0] = buffer[0].replace(replaceStyleRe, (_, pre, post) => `${pre}${stylesStr}${post}`)
      return
    }

Sources: src/helper/css/index.ts:85-108

If an existing style element is located within the buffer, appendStyle injects the accumulated CSS string directly into the tag via regular expression replacement. Otherwise, it generates an inline script block appending the rules to document.querySelector('#id').textContent (incorporating the CSP nonce if present) and prepends it to the response buffer.

 
  const nonce = nonceMap.get(context)
  const appendStyleScript = `<script${
    nonce ? ` nonce="${nonce}"` : ''
  }>document.querySelector('#${id}').textContent+=${JSON.stringify(stylesStr)}</script>`
 
  if (buffer) {
    buffer[0] = `${appendStyleScript}${buffer[0]}`
    return
  }
 
  return Promise.resolve(appendStyleScript)
}

Sources: src/helper/css/index.ts:109-121

Warning

If a style element matching the context identifier is not present in the render buffer when styles flush, Hono falls back to injecting an inline script containing a DOM query selector fallback. This requires client-side JavaScript execution to populate the stylesheet dynamically.

Sources: src/helper/css/index.ts:110-121

Client DOM Stylesheet Insertion

Stylesheet Discovery and Rule Splitting

The browser-side CSS subsystem initializes via createCssJsxDomObjects, which accepts an identifier to locate the corresponding <style> element in the DOM tree. The findStyleSheet utility queries document.querySelector<HTMLStyleElement>(\style#${id}`)?.sheetand initializes aSeton theCSSStyleSheetinstance under the property nameaddedStyles` to track injected class names and prevent duplicate rules.

export const createCssJsxDomObjects: CreateCssJsxDomObjectsType = ({ id }) => {
  let styleSheet: CSSStyleSheet | null | undefined = undefined
  const findStyleSheet = (): [CSSStyleSheet, Set<string>] | [] => {
    if (!styleSheet) {
      styleSheet = document.querySelector<HTMLStyleElement>(`style#${id}`)
        ?.sheet as CSSStyleSheet | null
      if (styleSheet) {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        ;(styleSheet as any).addedStyles = new Set<string>()
      }
    }
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return styleSheet ? [styleSheet, (styleSheet as any).addedStyles] : []
  }

Sources: src/jsx/dom/css.ts:77-90

Note

The dynamic rule insertion function splitRule parses rule text character by character, tracking nesting depth via curly braces while correctly consuming single and double quotes—including escaped backslashes—to avoid false splits inside string literals.

Sources: src/jsx/dom/css.ts:27-66

Runtime CSSOM Synchronization

When a client-side CSS object is evaluated via its toString method, it extracts its primary selector and style string, alongside any nested sub-selectors, passing them to insertRule. If the target stylesheet or its tracking set is not yet available in the DOM, insertRule defers execution via Promise.resolve().then(), retrying asynchronously until the element is mounted.

const insertRule = (className: string, styleString: string) => {
  const [sheet, addedStyles] = findStyleSheet()
  if (!sheet || !addedStyles) {
    Promise.resolve().then(() => {
      if (!findStyleSheet()[0]) {
        throw new Error('style sheet not found')
      }
      insertRule(className, styleString)
    })
    return
  }
 
  if (!addedStyles.has(className)) {
    addedStyles.add(className)
    ;(className.startsWith(PSEUDO_GLOBAL_SELECTOR)
      ? splitRule(styleString)
      : [`${className[0] === '@' ? '' : '.'}${className}{${styleString}}`]
    ).forEach((rule) => {
      sheet.insertRule(rule, sheet.cssRules.length)
    })
  }
}

Sources: src/jsx/dom/css.ts:92-113

Sources: src/jsx/dom/css.ts:92-124

If addedStyles does not yet contain the target class name, it marks the class as added and evaluates whether the selector begins with the pseudo-global marker :-hono-global. Pseudo-global rules are split into individual rule blocks using splitRule, while standard rules are formatted into valid CSS selectors before being appended to the CSSOM via sheet.insertRule(rule, sheet.cssRules.length).

if (!addedStyles.has(className)) {
  addedStyles.add(className)
  ;(className.startsWith(PSEUDO_GLOBAL_SELECTOR)
    ? splitRule(styleString)
    : [`${className[0] === '@' ? '' : '.'}${className}{${styleString}}`]
  ).forEach((rule) => {
    sheet.insertRule(rule, sheet.cssRules.length)
  })
}

Sources: src/jsx/dom/css.ts:104-112

Warning

If findStyleSheet fails to locate the target style element after an asynchronous tick during rule insertion, Hono throws an explicit Error('style sheet not found'), halting execution for that deferred stylesheet update.

Sources: src/jsx/dom/css.ts:95-100

View Transitions and Keyframes Support

Keyframe and Animation Rule Registration

Keyframe animations and view transitions are registered through dedicated helper functions exposed on the CSS context. The keyframes helper processes template strings and variable injections via keyframesCommon, generating a structured CssClassName object whose selector name is prefixed with @keyframes . When a keyframe identifier is referenced inside standard CSS string interpolation, buildStyleString detects the @keyframes prefix, registers the keyframe rule into the selector collection, and rewrites the interpolation value to output the extracted animation name correctly.

} else if ((value as CssClassName)[CLASS_NAME].startsWith('@keyframes ')) {
  selectors.push(value as CssClassName)
  styleString += ` ${(value as CssClassName)[CLASS_NAME].substring(11)} `
} else {

Sources: src/helper/css/common.ts:169-172

Note

Keyframe names are validated against a set of reserved CSS-wide keywords (default, inherit, initial, none, revert, revert-layer, and unset) to prevent invalid animation identifiers from breaking stylesheet compilation.

Sources: src/helper/css/common.ts:59-69

View-Transition Scoping and Pseudo-Global Handling

The viewTransition helper constructs scoped view-transition rules or transition names via viewTransitionCommon. In client DOM environments, view transitions and pseudo-global rules integrate directly with rule splitting and CSSOM insertion. When selectors start with the pseudo-global marker :-hono-global, Hono bypasses standard dot-prefixing and decomposes the rule string into individual sub-rules via splitRule before inserting them into the target stylesheet.

;(className.startsWith(PSEUDO_GLOBAL_SELECTOR)
  ? splitRule(styleString)
  : [`${className[0] === '@' ? '' : '.'}${className}{${styleString}}`]
).forEach((rule) => {
  sheet.insertRule(rule, sheet.cssRules.length)
})

Sources: src/jsx/dom/css.ts:106-111

Warning

Pseudo-global selectors must use the exact :-hono-global identifier string defined by PSEUDO_GLOBAL_SELECTOR to trigger rule-splitting behavior during browser CSSOM synchronization.

Sources: src/helper/css/common.ts:2, src/jsx/dom/css.ts:106-111

Was this page helpful?