Skip to content

feat(css): add classNameSlug option to createCssContext#4834

Merged
yusukebe merged 5 commits into
honojs:mainfrom
flow-pie:feat/css-classname-slug
Apr 4, 2026
Merged

feat(css): add classNameSlug option to createCssContext#4834
yusukebe merged 5 commits into
honojs:mainfrom
flow-pie:feat/css-classname-slug

Conversation

@flow-pie
Copy link
Copy Markdown
Contributor

Description

Adds an optional classNameSlug function to createCssContext that lets users customize generated CSS class names instead of the default css-1234567890 format.

Closes #4577

Before: Always generates css-1234567890
After: Pass classNameSlug: (hash, label, css) => string to get custom names

const { css, Style } = createCssContext({
  id: 'my-styles',
  classNameSlug: (hash, label) => label.trim() ? `h-${label.trim()}` : hash,
})

const hero = css`/* hero-section */ background: blue;`
// .h-hero-section { background: blue; }

Changes

  • src/helper/css/common.ts — exported ClassNameSlug type, added optional classNameSlug param to cssCommon()
  • src/helper/css/index.ts — added classNameSlug option to createCssContext(), exported ClassNameSlug type
  • src/helper/css/index.test.tsx — 4 new tests for custom slug, label extraction, fallback, and default behavior

Verification

  • All existing tests pass
  • TypeScript: 0 errors
  • ESLint: 0 warnings
  • Prettier: all files formatted

API

type ClassNameSlug = (hash: string, label: string, css: string) => string

createCssContext({
  id: string,
  classNameSlug?: ClassNameSlug,
})

The function receives:

  • hash — the default css-1234567890 string
  • label — extracted from /* comment */ at start of template (may be empty)
  • css — the minified CSS string

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 29, 2026

Codecov Report

❌ Patch coverage is 94.04762% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.87%. Comparing base (e1ae0eb) to head (be2d850).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
src/jsx/dom/css.ts 58.33% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4834      +/-   ##
==========================================
+ Coverage   92.84%   92.87%   +0.02%     
==========================================
  Files         177      177              
  Lines       11643    11726      +83     
  Branches     3469     3488      +19     
==========================================
+ Hits        10810    10890      +80     
- Misses        832      835       +3     
  Partials        1        1              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@yusukebe
Copy link
Copy Markdown
Member

@flow-pie Thank you for the PR!

@usualoma, can you review this? I think the direction of it is good!

@usualoma
Copy link
Copy Markdown
Member

Hi @flow-pie,

Thank you for creating the pull request. It looks good! Could you please consider addressing the following points?

  1. DOM-side createCssContext (src/jsx/dom/css.ts) is not updated to support classNameSlug.
  2. keyframesCommon and viewTransitionCommon in src/helper/css/common.ts don't propagate classNameSlug.
  3. ClassNameSlug type placement: It's currently placed right below the goober attribution comment, which makes it look like it's part of the goober-derived code. Moving it near the other type exports (around CssVariableType) would be clearer.
  4. ClassNameSlug type (minor, just my opinion): The third parameter name css could be confused with the css tagged template function. I'd suggest renaming it to styleString for clarity.

For example, I am considering the following additional changes.

diff --git i/src/helper/css/common.ts w/src/helper/css/common.ts
index f8259474..6a8b69d5 100644
--- i/src/helper/css/common.ts
+++ w/src/helper/css/common.ts
@@ -40,8 +40,6 @@ export const rawCssString = (value: string): CssEscapedString => {
  * https://github.com/cristianbote/goober/blob/master/src/core/to-hash.js
  * MIT License, Copyright (c) 2019 Cristian Bote
  */
-export type ClassNameSlug = (hash: string, label: string, css: string) => string
-
 const toHash = (str: string): string => {
   let i = 0,
     out = 11
@@ -92,6 +90,8 @@ type CssVariableAsyncType = Promise<CssVariableBasicType>
 type CssVariableArrayType = (CssVariableBasicType | CssVariableAsyncType)[]
 export type CssVariableType = CssVariableBasicType | CssVariableAsyncType | CssVariableArrayType
 
+export type ClassNameSlug = (hash: string, label: string, styleString: string) => string
+
 export const buildStyleString = (
   strings: TemplateStringsArray,
   values: CssVariableType[]
@@ -202,12 +202,15 @@ export const cxCommon = (
 
 export const keyframesCommon = (
   strings: TemplateStringsArray,
-  ...values: CssVariableType[]
+  values: CssVariableType[],
+  classNameSlug?: ClassNameSlug
 ): CssClassName => {
   const [label, styleString] = buildStyleString(strings, values)
+  const hash = toHash(label + styleString)
+  const name = classNameSlug ? classNameSlug(hash, label, styleString) : hash
   return {
     [SELECTOR]: '',
-    [CLASS_NAME]: `@keyframes ${toHash(label + styleString)}`,
+    [CLASS_NAME]: `@keyframes ${name}`,
     [STYLE_STRING]: styleString,
     [SELECTORS]: [],
     [EXTERNAL_CLASS_NAMES]: [],
@@ -215,7 +218,11 @@ export const keyframesCommon = (
 }
 
 type ViewTransitionType = {
-  (strings: TemplateStringsArray, values: CssVariableType[]): CssClassName
+  (
+    strings: TemplateStringsArray,
+    values: CssVariableType[],
+    classNameSlug?: ClassNameSlug
+  ): CssClassName
   (content: CssClassName): CssClassName
   (): CssClassName
 }
@@ -223,19 +230,20 @@ type ViewTransitionType = {
 let viewTransitionNameIndex = 0
 export const viewTransitionCommon: ViewTransitionType = ((
   strings: TemplateStringsArray | CssClassName | undefined,
-  values: CssVariableType[]
+  values: CssVariableType[],
+  classNameSlug?: ClassNameSlug
 ): CssClassName => {
   if (!strings) {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     strings = [`/* h-v-t ${viewTransitionNameIndex++} */`] as any
   }
   const content = Array.isArray(strings)
-    ? cssCommon(strings as TemplateStringsArray, values)
+    ? cssCommon(strings as TemplateStringsArray, values, classNameSlug)
     : (strings as CssClassName)
 
   const transitionName = content[CLASS_NAME]
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const res = cssCommon(['view-transition-name:', ''] as any, [transitionName])
+  const res = cssCommon(['view-transition-name:', ''] as any, [transitionName], classNameSlug)
 
   content[CLASS_NAME] = PSEUDO_GLOBAL_SELECTOR + content[CLASS_NAME]
   content[STYLE_STRING] = content[STYLE_STRING].replace(
diff --git i/src/helper/css/index.ts w/src/helper/css/index.ts
index 6a6296ae..7f3f6da6 100644
--- i/src/helper/css/index.ts
+++ w/src/helper/css/index.ts
@@ -156,14 +156,15 @@ export const createCssContext = ({
     return css(Array(args.length).fill('') as any, ...args)
   }
 
-  const keyframes = keyframesCommon
+  const keyframes: KeyframesType = (strings, ...values) =>
+    keyframesCommon(strings, values, classNameSlug)
 
   const viewTransition: ViewTransitionType = ((
     strings: TemplateStringsArray | Promise<string> | undefined,
     ...values: CssVariableType[]
   ) => {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    return newCssClassNameObject(viewTransitionCommon(strings as any, values))
+    return newCssClassNameObject(viewTransitionCommon(strings as any, values, classNameSlug))
   }) as ViewTransitionType
 
   const Style: StyleType = ({ children, nonce } = {}) =>
diff --git i/src/jsx/dom/css.ts w/src/jsx/dom/css.ts
index 4fdaf6ca..c6a67566 100644
--- i/src/jsx/dom/css.ts
+++ w/src/jsx/dom/css.ts
@@ -4,7 +4,7 @@
  */
 
 import type { FC, PropsWithChildren } from '../'
-import type { CssClassName, CssVariableType } from '../../helper/css/common'
+import type { ClassNameSlug, CssClassName, CssVariableType } from '../../helper/css/common'
 import {
   CLASS_NAME,
   DEFAULT_STYLE_ID,
@@ -168,7 +168,13 @@ interface DefaultContextType {
  * `createCssContext` is an experimental feature.
  * The API might be changed.
  */
-export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultContextType => {
+export const createCssContext = ({
+  id,
+  classNameSlug,
+}: {
+  id: Readonly<string>
+  classNameSlug?: ClassNameSlug
+}): DefaultContextType => {
   const [cssObject, Style] = createCssJsxDomObjects({ id })
 
   const newCssClassNameObject = (cssClassName: CssClassName): string => {
@@ -177,7 +183,7 @@ export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultConte
   }
 
   const css: CssType = (strings, ...values) => {
-    return newCssClassNameObject(cssCommon(strings, values))
+    return newCssClassNameObject(cssCommon(strings, values, classNameSlug))
   }
 
   const cx: CxType = (...args) => {
@@ -187,14 +193,15 @@ export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultConte
     return css(Array(args.length).fill('') as any, ...args)
   }
 
-  const keyframes: KeyframesType = keyframesCommon
+  const keyframes: KeyframesType = (strings, ...values) =>
+    keyframesCommon(strings, values, classNameSlug)
 
   const viewTransition: ViewTransitionType = ((
     strings: TemplateStringsArray | string | undefined,
     ...values: CssVariableType[]
   ) => {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    return newCssClassNameObject(viewTransitionCommon(strings as any, values))
+    return newCssClassNameObject(viewTransitionCommon(strings as any, values, classNameSlug))
   }) as ViewTransitionType
 
   return {

@flow-pie
Copy link
Copy Markdown
Contributor Author

Hi @flow-pie,

Thank you for creating the pull request. It looks good! Could you please consider addressing the following points?

  1. DOM-side createCssContext (src/jsx/dom/css.ts) is not updated to support classNameSlug.

  2. keyframesCommon and viewTransitionCommon in src/helper/css/common.ts don't propagate classNameSlug.

  3. ClassNameSlug type placement: It's currently placed right below the goober attribution comment, which makes it look like it's part of the goober-derived code. Moving it near the other type exports (around CssVariableType) would be clearer.

  4. ClassNameSlug type (minor, just my opinion): The third parameter name css could be confused with the css tagged template function. I'd suggest renaming it to styleString for clarity.

For example, I am considering the following additional changes.

diff --git i/src/helper/css/common.ts w/src/helper/css/common.ts
index f8259474..6a8b69d5 100644
--- i/src/helper/css/common.ts
+++ w/src/helper/css/common.ts
@@ -40,8 +40,6 @@ export const rawCssString = (value: string): CssEscapedString => {
  * https://github.com/cristianbote/goober/blob/master/src/core/to-hash.js
  * MIT License, Copyright (c) 2019 Cristian Bote
  */
-export type ClassNameSlug = (hash: string, label: string, css: string) => string
-
 const toHash = (str: string): string => {
   let i = 0,
     out = 11
@@ -92,6 +90,8 @@ type CssVariableAsyncType = Promise<CssVariableBasicType>
 type CssVariableArrayType = (CssVariableBasicType | CssVariableAsyncType)[]
 export type CssVariableType = CssVariableBasicType | CssVariableAsyncType | CssVariableArrayType
 
+export type ClassNameSlug = (hash: string, label: string, styleString: string) => string
+
 export const buildStyleString = (
   strings: TemplateStringsArray,
   values: CssVariableType[]
@@ -202,12 +202,15 @@ export const cxCommon = (
 
 export const keyframesCommon = (
   strings: TemplateStringsArray,
-  ...values: CssVariableType[]
+  values: CssVariableType[],
+  classNameSlug?: ClassNameSlug
 ): CssClassName => {
   const [label, styleString] = buildStyleString(strings, values)
+  const hash = toHash(label + styleString)
+  const name = classNameSlug ? classNameSlug(hash, label, styleString) : hash
   return {
     [SELECTOR]: '',
-    [CLASS_NAME]: `@keyframes ${toHash(label + styleString)}`,
+    [CLASS_NAME]: `@keyframes ${name}`,
     [STYLE_STRING]: styleString,
     [SELECTORS]: [],
     [EXTERNAL_CLASS_NAMES]: [],
@@ -215,7 +218,11 @@ export const keyframesCommon = (
 }
 
 type ViewTransitionType = {
-  (strings: TemplateStringsArray, values: CssVariableType[]): CssClassName
+  (
+    strings: TemplateStringsArray,
+    values: CssVariableType[],
+    classNameSlug?: ClassNameSlug
+  ): CssClassName
   (content: CssClassName): CssClassName
   (): CssClassName
 }
@@ -223,19 +230,20 @@ type ViewTransitionType = {
 let viewTransitionNameIndex = 0
 export const viewTransitionCommon: ViewTransitionType = ((
   strings: TemplateStringsArray | CssClassName | undefined,
-  values: CssVariableType[]
+  values: CssVariableType[],
+  classNameSlug?: ClassNameSlug
 ): CssClassName => {
   if (!strings) {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     strings = [`/* h-v-t ${viewTransitionNameIndex++} */`] as any
   }
   const content = Array.isArray(strings)
-    ? cssCommon(strings as TemplateStringsArray, values)
+    ? cssCommon(strings as TemplateStringsArray, values, classNameSlug)
     : (strings as CssClassName)
 
   const transitionName = content[CLASS_NAME]
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const res = cssCommon(['view-transition-name:', ''] as any, [transitionName])
+  const res = cssCommon(['view-transition-name:', ''] as any, [transitionName], classNameSlug)
 
   content[CLASS_NAME] = PSEUDO_GLOBAL_SELECTOR + content[CLASS_NAME]
   content[STYLE_STRING] = content[STYLE_STRING].replace(
diff --git i/src/helper/css/index.ts w/src/helper/css/index.ts
index 6a6296ae..7f3f6da6 100644
--- i/src/helper/css/index.ts
+++ w/src/helper/css/index.ts
@@ -156,14 +156,15 @@ export const createCssContext = ({
     return css(Array(args.length).fill('') as any, ...args)
   }
 
-  const keyframes = keyframesCommon
+  const keyframes: KeyframesType = (strings, ...values) =>
+    keyframesCommon(strings, values, classNameSlug)
 
   const viewTransition: ViewTransitionType = ((
     strings: TemplateStringsArray | Promise<string> | undefined,
     ...values: CssVariableType[]
   ) => {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    return newCssClassNameObject(viewTransitionCommon(strings as any, values))
+    return newCssClassNameObject(viewTransitionCommon(strings as any, values, classNameSlug))
   }) as ViewTransitionType
 
   const Style: StyleType = ({ children, nonce } = {}) =>
diff --git i/src/jsx/dom/css.ts w/src/jsx/dom/css.ts
index 4fdaf6ca..c6a67566 100644
--- i/src/jsx/dom/css.ts
+++ w/src/jsx/dom/css.ts
@@ -4,7 +4,7 @@
  */
 
 import type { FC, PropsWithChildren } from '../'
-import type { CssClassName, CssVariableType } from '../../helper/css/common'
+import type { ClassNameSlug, CssClassName, CssVariableType } from '../../helper/css/common'
 import {
   CLASS_NAME,
   DEFAULT_STYLE_ID,
@@ -168,7 +168,13 @@ interface DefaultContextType {
  * `createCssContext` is an experimental feature.
  * The API might be changed.
  */
-export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultContextType => {
+export const createCssContext = ({
+  id,
+  classNameSlug,
+}: {
+  id: Readonly<string>
+  classNameSlug?: ClassNameSlug
+}): DefaultContextType => {
   const [cssObject, Style] = createCssJsxDomObjects({ id })
 
   const newCssClassNameObject = (cssClassName: CssClassName): string => {
@@ -177,7 +183,7 @@ export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultConte
   }
 
   const css: CssType = (strings, ...values) => {
-    return newCssClassNameObject(cssCommon(strings, values))
+    return newCssClassNameObject(cssCommon(strings, values, classNameSlug))
   }
 
   const cx: CxType = (...args) => {
@@ -187,14 +193,15 @@ export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultConte
     return css(Array(args.length).fill('') as any, ...args)
   }
 
-  const keyframes: KeyframesType = keyframesCommon
+  const keyframes: KeyframesType = (strings, ...values) =>
+    keyframesCommon(strings, values, classNameSlug)
 
   const viewTransition: ViewTransitionType = ((
     strings: TemplateStringsArray | string | undefined,
     ...values: CssVariableType[]
   ) => {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    return newCssClassNameObject(viewTransitionCommon(strings as any, values))
+    return newCssClassNameObject(viewTransitionCommon(strings as any, values, classNameSlug))
   }) as ViewTransitionType
 
   return {

Hi @usualoma

I really appreciate the detailed feedback . I’ll go through the points raised and follow up shortly.

Add an optional classNameSlug function to createCssContext that lets
users customize generated CSS class names instead of the default
hash-based 'css-1234567890' format.

The function receives (hash, label, styleString) and returns the desired
class name string. This enables readable, debug-friendly class names
like 'h-hero-section' while preserving hash-based uniqueness.

Addresses review feedback:
- Move ClassNameSlug type near CssVariableType exports
- Rename third parameter from css to styleString
- Propagate classNameSlug to keyframesCommon and viewTransitionCommon
- Add classNameSlug support to DOM-side createCssContext
- Add TSDoc documentation

Closes honojs#4577
@flow-pie flow-pie force-pushed the feat/css-classname-slug branch from 017fb5c to 7920b4f Compare March 30, 2026 12:55
@flow-pie
Copy link
Copy Markdown
Contributor Author

Hi @usualoma, I’ve amended my last commit and addressed all your review feedback:

  • createCssContext on the DOM side now supports classNameSlug
  • keyframesCommon & viewTransitionCommon propagate classNameSlug
  • Moved ClassNameSlug type closer to CssVariableType exports
  • Renamed cssstyleString
  • Added tests for keyframes, viewTransition, and DOM contexts
  • Added TSDoc docs for all the new stuff

@usualoma
Copy link
Copy Markdown
Member

Hi @flow-pie,

Thanks for the update! I think this content is fine.
However, going forward, please refrain from performing a force push once the review process has begun.
Thank you for your cooperation.

Comment thread src/jsx/dom/css.test.tsx Outdated
Comment thread src/helper/css/common.ts Outdated
@yusukebe
Copy link
Copy Markdown
Member

Hey @flow-pie @usualoma

I added the comments. Can you confirm them?

@flow-pie
Copy link
Copy Markdown
Contributor Author

flow-pie commented Mar 31, 2026

Hey @flow-pie @usualoma

I added the comments. Can you confirm them?

Hey @yusukebe
You're both right. My main question is whether normalization should apply only to user-provided slugs or also to the default hash. Since the hash is already safe, normalization is only needed for custom classNameSlug returns, which can be handled in sanitizeClassName. Also applying aggressive transformations like lowercasing and someone deliberately uses camelCase, forcing lowercase would change their intended behavior, even though CSS class names are case-sensitive.

Based on the thread signals, I think the best approach is to add minimal safe sanitization for classNameSlug:

  • Trims leading/trailing whitespace
  • Normalizes spaces → hyphens (e.g., "ultra fast""ultra-fast")
  • Restricts characters to [a-zA-Z0-9_-]
  • Falls back to the hash if the result is empty

Pushing the update shortly.

Normalize and sanitize the return value of classNameSlug to prevent
CSS injection and improve readability:

- Trim leading/trailing whitespace
- Collapse whitespace sequences to hyphens (e.g. 'ultra fast' -> 'ultra-fast')
- Strip characters outside [a-zA-Z0-9_-]
- Fall back to hash if result is empty

Addresses review feedback from yusukebe and usualoma regarding
potential CSS injection through malicious classNameSlug functions.
@usualoma
Copy link
Copy Markdown
Member

usualoma commented Apr 1, 2026

@flow-pie
Thanks for the update!

First, as a general rule, I don't want to compromise performance in cases where ClassNameSlug isn't used. I consider the hash generated internally to be safe, so I don't want to perform any checks.

Also, in this context (and generally in many cases), I don't think sanitize is appropriate. This is because problems would arise if user-generated class-name-! and class-name-? were treated as identical by sanitize.

Furthermore, to ensure users do not have to use .trim() within ClassNameSlug, normalization must be performed before calling ClassNameSlug.

It also seems that CSS and keyframe values will require slightly stricter checks.

Here is what I am considering:

  • validateClassName: Use ^-?[_a-zA-Z][_a-zA-Z0-9-]*$ instead of [a-zA-Z0-9_-] to enforce valid CSS identifier syntax (e.g. reject names starting with a digit like 1hero)
  • validateKeyframeName: Additionally reject CSS-wide reserved keywords (none, inherit, initial, unset, default, revert, revert-layer) which are invalid as @keyframes names
diff --git c/src/helper/css/common.ts w/src/helper/css/common.ts
index 3029a276..576876c2 100644
--- c/src/helper/css/common.ts
+++ w/src/helper/css/common.ts
@@ -49,14 +49,25 @@ const toHash = (str: string): string => {
   return 'css-' + out
 }
 
-const sanitizeClassName = (name: string, fallback: string): string => {
-  const sanitized = name
-    .trim()
-    .replace(/\s+/g, '-')
-    .replace(/[^a-zA-Z0-9_-]/g, '')
-  return sanitized || fallback
+const normalizeLabel = (label: string): string => {
+  return label.trim().replace(/\s+/g, '-')
 }
 
+const validateClassName = (name: string): string | undefined =>
+  !name || !/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(name) ? undefined : name
+
+const RESERVED_KEYFRAME_NAMES = new Set([
+  'default',
+  'inherit',
+  'initial',
+  'none',
+  'revert',
+  'revert-layer',
+  'unset',
+])
+const validateKeyframeName = (name: string): string | undefined =>
+  !validateClassName(name) || RESERVED_KEYFRAME_NAMES.has(name.toLowerCase()) ? undefined : name
+
 const cssStringReStr: string = [
   '"(?:(?:\\\\[\\s\\S]|[^"\\\\])*)"', // double quoted string
 
@@ -102,11 +113,11 @@ export type CssVariableType = CssVariableBasicType | CssVariableAsyncType | CssV
  * A function that customizes generated CSS class names.
  *
  * @param hash - The default hash-based class name (e.g. `css-1234567890`)
- * @param label - The comment label extracted from the CSS template, may be empty
+ * @param label - The comment label extracted from the CSS template, may be empty.
+ *   Whitespace is trimmed and inner spaces are replaced with hyphens.
  * @param styleString - The minified CSS style string
- * @returns The custom class name to use. The returned value will be normalized
- *   (whitespace trimmed and collapsed to hyphens) and sanitized (only
- *   `[a-zA-Z0-9_-]` characters are kept) internally.
+ * @returns The custom class name to use. Must be a safe CSS identifier;
+ *   otherwise, the default hash is used as a fallback.
  */
 export type ClassNameSlug = (hash: string, label: string, styleString: string) => string
 
@@ -185,7 +196,9 @@ export const cssCommon = (
   const hash = toHash(label + thisStyleString)
   const selector =
     (isPseudoGlobal ? PSEUDO_GLOBAL_SELECTOR : '') +
-    sanitizeClassName(classNameSlug ? classNameSlug(hash, label, thisStyleString) : hash, hash)
+    ((classNameSlug &&
+      validateClassName(classNameSlug(hash, normalizeLabel(label), thisStyleString))) ||
+      hash)
   const className = (
     isPseudoGlobal ? selectors.map((s) => s[CLASS_NAME]) : [selector, ...externalClassNames]
   ).join(' ')
@@ -225,10 +238,11 @@ export const keyframesCommon = (
 ): CssClassName => {
   const [label, styleString] = buildStyleString(strings, values)
   const hash = toHash(label + styleString)
-  const name = sanitizeClassName(
-    classNameSlug ? classNameSlug(hash, label, styleString) : hash,
+  const name =
+    (classNameSlug &&
+      validateKeyframeName(classNameSlug(hash, normalizeLabel(label), styleString))) ||
     hash
-  )
+
   return {
     [SELECTOR]: '',
     [CLASS_NAME]: `@keyframes ${name}`,
diff --git c/src/helper/css/index.test.tsx w/src/helper/css/index.test.tsx
index cac34437..a5087fdf 100644
--- c/src/helper/css/index.test.tsx
+++ w/src/helper/css/index.test.tsx
@@ -229,10 +229,10 @@ describe('CSS Helper', () => {
     it('Should pass label to classNameSlug', async () => {
       const { css: customCss, Style: customStyle } = createCssContext({
         id: 'label-slug',
-        classNameSlug: (hash, label) => (label ? `h${label.trim()}` : hash),
+        classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
       })
       const headerClass = customCss`
-        /* hero-section */
+        /* hero section */
         background-color: blue;
       `
       const template = (
@@ -242,10 +242,39 @@ describe('CSS Helper', () => {
         </>
       )
       const result = await toString(template)
-      expect(result).toContain('.hhero-section')
+      expect(result).toContain('.h-hero-section')
       expect(result).toContain('background-color:blue')
     })
 
+    it('Should fall back to different hash values for labels with special characters', async () => {
+      const { css: customCss, Style: customStyle } = createCssContext({
+        id: 'label-deferent-slug',
+        classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
+      })
+      const headerClass1 = customCss`
+        /* hero section! */
+        background-color: blue;
+      `
+      const headerClass2 = customCss`
+        /* hero section? */
+        background-color: blue;
+      `
+      const template = (
+        <>
+          {customStyle()}
+          <div class={headerClass1}>Hero</div>
+          <div class={headerClass2}>Hero</div>
+        </>
+      )
+      const result = await toString(template)
+
+      const classes = new Set<string>()
+      result.match(/class="(.*?)"/g)?.forEach((match) => {
+        classes.add(match)
+      })
+      expect(classes.size).toBe(2)
+    })
+
     it('Should fall back to hash when label is empty', async () => {
       const { css: customCss, Style: customStyle } = createCssContext({
         id: 'fallback-slug',
@@ -287,7 +316,7 @@ describe('CSS Helper', () => {
         Style: customStyle,
       } = createCssContext({
         id: 'kf-slug',
-        classNameSlug: (hash, label) => (label.trim() ? `h-${label.trim()}` : hash),
+        classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
       })
       const animation = customKeyframes`
         /* fade-in */
@@ -315,7 +344,7 @@ describe('CSS Helper', () => {
         Style: customStyle,
       } = createCssContext({
         id: 'vt-slug',
-        classNameSlug: (hash, label) => (label.trim() ? `h-${label.trim()}` : hash),
+        classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
       })
       const transition = customViewTransition(customCss`
         /* hero */
@@ -331,30 +360,10 @@ describe('CSS Helper', () => {
       expect(result).toContain('view-transition-name:h-hero')
     })
 
-    it('Should sanitize classNameSlug output to safe characters only', async () => {
-      const { css: customCss, Style: customStyle } = createCssContext({
-        id: 'sanitize-slug',
-        classNameSlug: () => '  bad name{color:red} ',
-      })
-      const headerClass = customCss`
-        display: flex;
-      `
-      const template = (
-        <>
-          {customStyle()}
-          <h1 class={headerClass}>Hello!</h1>
-        </>
-      )
-      const result = await toString(template)
-      expect(result).toContain('.bad-namecolorred{display:flex}')
-      expect(result).toContain('class="bad-namecolorred"')
-      expect(result).not.toContain('bad name{color:red}')
-    })
-
-    it('Should fall back to hash when classNameSlug returns only invalid characters', async () => {
+    it('Should fall back to hash when classNameSlug returns invalid characters', async () => {
       const { css: customCss, Style: customStyle } = createCssContext({
         id: 'empty-slug',
-        classNameSlug: () => '!!!:::',
+        classNameSlug: () => 'name!',
       })
       const headerClass = customCss`
         display: flex;
@@ -370,10 +379,30 @@ describe('CSS Helper', () => {
       expect(result).toContain('display:flex')
     })
 
+    it('Should fall back to hash when classNameSlug starts with a number', async () => {
+      const { css: customCss, Style: customStyle } = createCssContext({
+        id: 'numeric-slug',
+        classNameSlug: () => '1hero',
+      })
+      const headerClass = customCss`
+        display: flex;
+      `
+      const template = (
+        <>
+          {customStyle()}
+          <h1 class={headerClass}>Hello!</h1>
+        </>
+      )
+      const result = await toString(template)
+      expect(result).toContain('.css-')
+      expect(result).toContain('display:flex')
+      expect(result).not.toContain('.1hero')
+    })
+
     it('Should fall back to hash when label is empty and classNameSlug returns label', async () => {
       const { css: customCss, Style: customStyle } = createCssContext({
         id: 'empty-label-slug',
-        classNameSlug: (_, label) => label.trim(),
+        classNameSlug: (_, label) => label,
       })
       const headerClass = customCss`
         display: flex;
@@ -388,6 +417,35 @@ describe('CSS Helper', () => {
       expect(result).toContain('.css-')
       expect(result).toContain('display:flex')
     })
+
+    it('Should fall back to hash when keyframes name is a reserved keyword', async () => {
+      const {
+        css: customCss,
+        keyframes: customKeyframes,
+        Style: customStyle,
+      } = createCssContext({
+        id: 'reserved-kf-slug',
+        classNameSlug: () => 'none',
+      })
+      const animation = customKeyframes`
+        from { opacity: 0; }
+        to { opacity: 1; }
+      `
+      const headerClass = customCss`
+        animation: ${animation} 1s ease-in-out;
+      `
+      const template = (
+        <>
+          {customStyle()}
+          <h1 class={headerClass}>Hello!</h1>
+        </>
+      )
+      const result = await toString(template)
+      expect(result).toContain('@keyframes css-')
+      expect(result).toContain('animation:css-')
+      expect(result).not.toContain('@keyframes none')
+      expect(result).not.toContain('animation:none 1s ease-in-out')
+    })
   })
 
   describe('with application', () => {

@flow-pie
Copy link
Copy Markdown
Contributor Author

flow-pie commented Apr 1, 2026

@usualoma

Thanks for the clarification — avoiding sanitization and preserving user intent, as well as keeping the default path free from extra overhead makes sense.

There's one small concern I have around developer experience when validation fails and silently falls back to the hash. In cases like:

classNameSlug: () => 'hero!'

it may not be immediately obvious why the custom name isn’t applied.

To address this without impacting production performance, I’m thinking it would be wise adding a lightweight dev-only warning when an invalid slug is returned (e.g. gated behind "process.env.NODE_ENV !== 'production'"). This would help catch issues early while keeping runtime cost at zero in production.

Would you be open to that approach?

@flow-pie
Copy link
Copy Markdown
Contributor Author

flow-pie commented Apr 1, 2026

@flow-pie Thanks for the update!

First, as a general rule, I don't want to compromise performance in cases where ClassNameSlug isn't used. I consider the hash generated internally to be safe, so I don't want to perform any checks.

Also, in this context (and generally in many cases), I don't think sanitize is appropriate. This is because problems would arise if user-generated class-name-! and class-name-? were treated as identical by sanitize.

Furthermore, to ensure users do not have to use .trim() within ClassNameSlug, normalization must be performed before calling ClassNameSlug.

It also seems that CSS and keyframe values will require slightly stricter checks.

Here is what I am considering:

  • validateClassName: Use ^-?[_a-zA-Z][_a-zA-Z0-9-]*$ instead of [a-zA-Z0-9_-] to enforce valid CSS identifier syntax (e.g. reject names starting with a digit like 1hero)
  • validateKeyframeName: Additionally reject CSS-wide reserved keywords (none, inherit, initial, unset, default, revert, revert-layer) which are invalid as @keyframes names
diff --git c/src/helper/css/common.ts w/src/helper/css/common.ts
index 3029a276..576876c2 100644
--- c/src/helper/css/common.ts
+++ w/src/helper/css/common.ts
@@ -49,14 +49,25 @@ const toHash = (str: string): string => {
   return 'css-' + out
 }
 
-const sanitizeClassName = (name: string, fallback: string): string => {
-  const sanitized = name
-    .trim()
-    .replace(/\s+/g, '-')
-    .replace(/[^a-zA-Z0-9_-]/g, '')
-  return sanitized || fallback
+const normalizeLabel = (label: string): string => {
+  return label.trim().replace(/\s+/g, '-')
 }
 
+const validateClassName = (name: string): string | undefined =>
+  !name || !/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(name) ? undefined : name
+
+const RESERVED_KEYFRAME_NAMES = new Set([
+  'default',
+  'inherit',
+  'initial',
+  'none',
+  'revert',
+  'revert-layer',
+  'unset',
+])
+const validateKeyframeName = (name: string): string | undefined =>
+  !validateClassName(name) || RESERVED_KEYFRAME_NAMES.has(name.toLowerCase()) ? undefined : name
+
 const cssStringReStr: string = [
   '"(?:(?:\\\\[\\s\\S]|[^"\\\\])*)"', // double quoted string
 
@@ -102,11 +113,11 @@ export type CssVariableType = CssVariableBasicType | CssVariableAsyncType | CssV
  * A function that customizes generated CSS class names.
  *
  * @param hash - The default hash-based class name (e.g. `css-1234567890`)
- * @param label - The comment label extracted from the CSS template, may be empty
+ * @param label - The comment label extracted from the CSS template, may be empty.
+ *   Whitespace is trimmed and inner spaces are replaced with hyphens.
  * @param styleString - The minified CSS style string
- * @returns The custom class name to use. The returned value will be normalized
- *   (whitespace trimmed and collapsed to hyphens) and sanitized (only
- *   `[a-zA-Z0-9_-]` characters are kept) internally.
+ * @returns The custom class name to use. Must be a safe CSS identifier;
+ *   otherwise, the default hash is used as a fallback.
  */
 export type ClassNameSlug = (hash: string, label: string, styleString: string) => string
 
@@ -185,7 +196,9 @@ export const cssCommon = (
   const hash = toHash(label + thisStyleString)
   const selector =
     (isPseudoGlobal ? PSEUDO_GLOBAL_SELECTOR : '') +
-    sanitizeClassName(classNameSlug ? classNameSlug(hash, label, thisStyleString) : hash, hash)
+    ((classNameSlug &&
+      validateClassName(classNameSlug(hash, normalizeLabel(label), thisStyleString))) ||
+      hash)
   const className = (
     isPseudoGlobal ? selectors.map((s) => s[CLASS_NAME]) : [selector, ...externalClassNames]
   ).join(' ')
@@ -225,10 +238,11 @@ export const keyframesCommon = (
 ): CssClassName => {
   const [label, styleString] = buildStyleString(strings, values)
   const hash = toHash(label + styleString)
-  const name = sanitizeClassName(
-    classNameSlug ? classNameSlug(hash, label, styleString) : hash,
+  const name =
+    (classNameSlug &&
+      validateKeyframeName(classNameSlug(hash, normalizeLabel(label), styleString))) ||
     hash
-  )
+
   return {
     [SELECTOR]: '',
     [CLASS_NAME]: `@keyframes ${name}`,
diff --git c/src/helper/css/index.test.tsx w/src/helper/css/index.test.tsx
index cac34437..a5087fdf 100644
--- c/src/helper/css/index.test.tsx
+++ w/src/helper/css/index.test.tsx
@@ -229,10 +229,10 @@ describe('CSS Helper', () => {
     it('Should pass label to classNameSlug', async () => {
       const { css: customCss, Style: customStyle } = createCssContext({
         id: 'label-slug',
-        classNameSlug: (hash, label) => (label ? `h${label.trim()}` : hash),
+        classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
       })
       const headerClass = customCss`
-        /* hero-section */
+        /* hero section */
         background-color: blue;
       `
       const template = (
@@ -242,10 +242,39 @@ describe('CSS Helper', () => {
         </>
       )
       const result = await toString(template)
-      expect(result).toContain('.hhero-section')
+      expect(result).toContain('.h-hero-section')
       expect(result).toContain('background-color:blue')
     })
 
+    it('Should fall back to different hash values for labels with special characters', async () => {
+      const { css: customCss, Style: customStyle } = createCssContext({
+        id: 'label-deferent-slug',
+        classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
+      })
+      const headerClass1 = customCss`
+        /* hero section! */
+        background-color: blue;
+      `
+      const headerClass2 = customCss`
+        /* hero section? */
+        background-color: blue;
+      `
+      const template = (
+        <>
+          {customStyle()}
+          <div class={headerClass1}>Hero</div>
+          <div class={headerClass2}>Hero</div>
+        </>
+      )
+      const result = await toString(template)
+
+      const classes = new Set<string>()
+      result.match(/class="(.*?)"/g)?.forEach((match) => {
+        classes.add(match)
+      })
+      expect(classes.size).toBe(2)
+    })
+
     it('Should fall back to hash when label is empty', async () => {
       const { css: customCss, Style: customStyle } = createCssContext({
         id: 'fallback-slug',
@@ -287,7 +316,7 @@ describe('CSS Helper', () => {
         Style: customStyle,
       } = createCssContext({
         id: 'kf-slug',
-        classNameSlug: (hash, label) => (label.trim() ? `h-${label.trim()}` : hash),
+        classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
       })
       const animation = customKeyframes`
         /* fade-in */
@@ -315,7 +344,7 @@ describe('CSS Helper', () => {
         Style: customStyle,
       } = createCssContext({
         id: 'vt-slug',
-        classNameSlug: (hash, label) => (label.trim() ? `h-${label.trim()}` : hash),
+        classNameSlug: (hash, label) => (label ? `h-${label}` : hash),
       })
       const transition = customViewTransition(customCss`
         /* hero */
@@ -331,30 +360,10 @@ describe('CSS Helper', () => {
       expect(result).toContain('view-transition-name:h-hero')
     })
 
-    it('Should sanitize classNameSlug output to safe characters only', async () => {
-      const { css: customCss, Style: customStyle } = createCssContext({
-        id: 'sanitize-slug',
-        classNameSlug: () => '  bad name{color:red} ',
-      })
-      const headerClass = customCss`
-        display: flex;
-      `
-      const template = (
-        <>
-          {customStyle()}
-          <h1 class={headerClass}>Hello!</h1>
-        </>
-      )
-      const result = await toString(template)
-      expect(result).toContain('.bad-namecolorred{display:flex}')
-      expect(result).toContain('class="bad-namecolorred"')
-      expect(result).not.toContain('bad name{color:red}')
-    })
-
-    it('Should fall back to hash when classNameSlug returns only invalid characters', async () => {
+    it('Should fall back to hash when classNameSlug returns invalid characters', async () => {
       const { css: customCss, Style: customStyle } = createCssContext({
         id: 'empty-slug',
-        classNameSlug: () => '!!!:::',
+        classNameSlug: () => 'name!',
       })
       const headerClass = customCss`
         display: flex;
@@ -370,10 +379,30 @@ describe('CSS Helper', () => {
       expect(result).toContain('display:flex')
     })
 
+    it('Should fall back to hash when classNameSlug starts with a number', async () => {
+      const { css: customCss, Style: customStyle } = createCssContext({
+        id: 'numeric-slug',
+        classNameSlug: () => '1hero',
+      })
+      const headerClass = customCss`
+        display: flex;
+      `
+      const template = (
+        <>
+          {customStyle()}
+          <h1 class={headerClass}>Hello!</h1>
+        </>
+      )
+      const result = await toString(template)
+      expect(result).toContain('.css-')
+      expect(result).toContain('display:flex')
+      expect(result).not.toContain('.1hero')
+    })
+
     it('Should fall back to hash when label is empty and classNameSlug returns label', async () => {
       const { css: customCss, Style: customStyle } = createCssContext({
         id: 'empty-label-slug',
-        classNameSlug: (_, label) => label.trim(),
+        classNameSlug: (_, label) => label,
       })
       const headerClass = customCss`
         display: flex;
@@ -388,6 +417,35 @@ describe('CSS Helper', () => {
       expect(result).toContain('.css-')
       expect(result).toContain('display:flex')
     })
+
+    it('Should fall back to hash when keyframes name is a reserved keyword', async () => {
+      const {
+        css: customCss,
+        keyframes: customKeyframes,
+        Style: customStyle,
+      } = createCssContext({
+        id: 'reserved-kf-slug',
+        classNameSlug: () => 'none',
+      })
+      const animation = customKeyframes`
+        from { opacity: 0; }
+        to { opacity: 1; }
+      `
+      const headerClass = customCss`
+        animation: ${animation} 1s ease-in-out;
+      `
+      const template = (
+        <>
+          {customStyle()}
+          <h1 class={headerClass}>Hello!</h1>
+        </>
+      )
+      const result = await toString(template)
+      expect(result).toContain('@keyframes css-')
+      expect(result).toContain('animation:css-')
+      expect(result).not.toContain('@keyframes none')
+      expect(result).not.toContain('animation:none 1s ease-in-out')
+    })
   })
 
   describe('with application', () => {

regex ^-?[_a-zA-Z][_a-zA-Z0-9-]*$ does not allow for Unicode characters in slugs (e.g., .). While CSS technically supports them!

@usualoma
Copy link
Copy Markdown
Member

usualoma commented Apr 1, 2026

@flow-pie
Thanks for your input!

I think your point is valid. However, since the current code in Hono references process.env, I’d prefer not to include it.

Since this is essentially the user’s responsibility, I don’t think we need to display anything, but one option might be to use console.warn() regardless of the environment (development/production).

What do you think, @yusukebe?

@usualoma
Copy link
Copy Markdown
Member

usualoma commented Apr 1, 2026

regex ^-?[_a-zA-Z][_a-zA-Z0-9-]*$ does not allow for Unicode characters in slugs (e.g., .). While CSS technically supports them!

Thanks for pointing that out. I know it’s possible to use multibyte characters in class names, but I just can’t imagine any users deliberately trying to use them in this context.

@yusukebe
Copy link
Copy Markdown
Member

yusukebe commented Apr 1, 2026

Hey @flow-pie @usualoma

I don't prefer to use an environment variable. In the hono code, we don't use it so many times because it depends on the runtime. Instead, how about adding a fallback method like onInvalidSlug? It's more Hono-way.

createCssContext({
  id: 'custom-slug',
  classNameSlug: (hash, _label, _css) => `btn-${hash.split('-')[1]}`,
  onInvalidSlug: () => {
    console.error('Invalid slug') // The default is  console.warn
  },
})

@usualoma
Copy link
Copy Markdown
Member

usualoma commented Apr 1, 2026

Hi @yusukebe,

Thanks for the comment!
It looks like the API will be more robust than I imagined, but that's great too.

@exoego
Copy link
Copy Markdown
Contributor

exoego commented Apr 1, 2026

@yusukebe BE CAREFUL.
The PR author stole my avatar (it is my own, drawn by my family)

@flow-pie
Copy link
Copy Markdown
Contributor Author

flow-pie commented Apr 1, 2026

@yusukebe BE CAREFUL. The PR author stole my avatar (it is my own, drawn by my family)

Hey, my bad — I didn’t realize it was a personal artwork. I just thought it looked cool and used it without thinking too much about it. I’ve changed my avatar. Sorry about that.

Add strict CSS identifier validation for classNameSlug output and
onInvalidSlug callback for invalid slugs, unified across server
and DOM-side createCssContext.

- Replace sanitizeClassName with validateClassName/validateKeyframeName
  (rejects digit-leading names and reserved @Keyframes keywords)
- Add onInvalidSlug option (defaults to console.warn)
- Normalize labels (trim + hyphenate) before passing to classNameSlug
@usualoma
Copy link
Copy Markdown
Member

usualoma commented Apr 1, 2026

Hi @flow-pie,

I truly appreciate your contributions to this PR. However, I have to be direct: using another person's avatar without permission is a serious violation of trust, and I believe this is the kind of act that warrants considering a ban from this project.

I do believe you have engaged with the discussion here in good faith, which makes this situation all the more disappointing. I want you to understand how significant and problematic this act was.

We will discuss how to handle the avatar issue with @yusukebe, but in the meantime, I will leave some comments on the code itself.

@usualoma
Copy link
Copy Markdown
Member

usualoma commented Apr 1, 2026

I think it would be better to do it this way:

  • Now that slug generation and validation are separate, validate* functions just return boolean instead of the string itself
  • Eliminated unnecessary name ||= hash assignment in keyframesCommon when classNameSlug is not provided
diff --git c/src/helper/css/common.ts w/src/helper/css/common.ts
index dcf6442d..7ea12390 100644
--- c/src/helper/css/common.ts
+++ w/src/helper/css/common.ts
@@ -53,8 +53,7 @@ const normalizeLabel = (label: string): string => {
   return label.trim().replace(/\s+/g, '-')
 }
 
-const validateClassName = (name: string): string | undefined =>
-  !name || !/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(name) ? undefined : name
+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([
@@ -66,8 +65,8 @@ const RESERVED_KEYFRAME_NAMES = new Set([
   'revert-layer',
   'unset',
 ])
-const validateKeyframeName = (name: string): string | undefined =>
-  !validateClassName(name) || RESERVED_KEYFRAME_NAMES.has(name.toLowerCase()) ? undefined : name
+const isValidKeyframeName = (name: string): boolean =>
+  isValidClassName(name) && !RESERVED_KEYFRAME_NAMES.has(name.toLowerCase())
 
 const cssStringReStr: string = [
   '"(?:(?:\\\\[\\s\\S]|[^"\\\\])*)"', // double quoted string
@@ -213,9 +212,12 @@ export const cssCommon = (
   let customSlug: string | undefined
   if (classNameSlug) {
     const slug = classNameSlug(hash, normalizeLabel(label), thisStyleString)
-    customSlug = validateClassName(slug)
-    if (slug && !customSlug) {
-      ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+    if (slug) {
+      if (isValidClassName(slug)) {
+        customSlug = slug
+      } else {
+        ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+      }
     }
   }
   const selector = (isPseudoGlobal ? PSEUDO_GLOBAL_SELECTOR : '') + (customSlug || hash)
@@ -259,19 +261,21 @@ export const keyframesCommon = (
 ): CssClassName => {
   const [label, styleString] = buildStyleString(strings, values)
   const hash = toHash(label + styleString)
-  let name: string | undefined
+  let customSlug: string | undefined
   if (classNameSlug) {
     const slug = classNameSlug(hash, normalizeLabel(label), styleString)
-    name = validateKeyframeName(slug)
-    if (slug && !name) {
-      ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+    if (slug) {
+      if (isValidKeyframeName(slug)) {
+        customSlug = slug
+      } else {
+        ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+      }
     }
   }
-  name ||= hash
 
   return {
     [SELECTOR]: '',
-    [CLASS_NAME]: `@keyframes ${name}`,
+    [CLASS_NAME]: `@keyframes ${customSlug || hash}`,
     [STYLE_STRING]: styleString,
     [SELECTORS]: [],
     [EXTERNAL_CLASS_NAMES]: [],

@flow-pie
Copy link
Copy Markdown
Contributor Author

flow-pie commented Apr 2, 2026

I think it would be better to do it this way:

  • Now that slug generation and validation are separate, validate* functions just return boolean instead of the string itself
  • Eliminated unnecessary name ||= hash assignment in keyframesCommon when classNameSlug is not provided
diff --git c/src/helper/css/common.ts w/src/helper/css/common.ts
index dcf6442d..7ea12390 100644
--- c/src/helper/css/common.ts
+++ w/src/helper/css/common.ts
@@ -53,8 +53,7 @@ const normalizeLabel = (label: string): string => {
   return label.trim().replace(/\s+/g, '-')
 }
 
-const validateClassName = (name: string): string | undefined =>
-  !name || !/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(name) ? undefined : name
+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([
@@ -66,8 +65,8 @@ const RESERVED_KEYFRAME_NAMES = new Set([
   'revert-layer',
   'unset',
 ])
-const validateKeyframeName = (name: string): string | undefined =>
-  !validateClassName(name) || RESERVED_KEYFRAME_NAMES.has(name.toLowerCase()) ? undefined : name
+const isValidKeyframeName = (name: string): boolean =>
+  isValidClassName(name) && !RESERVED_KEYFRAME_NAMES.has(name.toLowerCase())
 
 const cssStringReStr: string = [
   '"(?:(?:\\\\[\\s\\S]|[^"\\\\])*)"', // double quoted string
@@ -213,9 +212,12 @@ export const cssCommon = (
   let customSlug: string | undefined
   if (classNameSlug) {
     const slug = classNameSlug(hash, normalizeLabel(label), thisStyleString)
-    customSlug = validateClassName(slug)
-    if (slug && !customSlug) {
-      ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+    if (slug) {
+      if (isValidClassName(slug)) {
+        customSlug = slug
+      } else {
+        ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+      }
     }
   }
   const selector = (isPseudoGlobal ? PSEUDO_GLOBAL_SELECTOR : '') + (customSlug || hash)
@@ -259,19 +261,21 @@ export const keyframesCommon = (
 ): CssClassName => {
   const [label, styleString] = buildStyleString(strings, values)
   const hash = toHash(label + styleString)
-  let name: string | undefined
+  let customSlug: string | undefined
   if (classNameSlug) {
     const slug = classNameSlug(hash, normalizeLabel(label), styleString)
-    name = validateKeyframeName(slug)
-    if (slug && !name) {
-      ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+    if (slug) {
+      if (isValidKeyframeName(slug)) {
+        customSlug = slug
+      } else {
+        ;(onInvalidSlug || defaultOnInvalidSlug)(slug)
+      }
     }
   }
-  name ||= hash
 
   return {
     [SELECTOR]: '',
-    [CLASS_NAME]: `@keyframes ${name}`,
+    [CLASS_NAME]: `@keyframes ${customSlug || hash}`,
     [STYLE_STRING]: styleString,
     [SELECTORS]: [],
     [EXTERNAL_CLASS_NAMES]: [],

Thanks, . I’ll adjust the implementation to align with your feedback .

@yusukebe
Copy link
Copy Markdown
Member

yusukebe commented Apr 2, 2026

@exoego Thank you for your comments and follow-up.

@flow-pie As @usualoma said, what you did hurt some, and it is not acceptable in our Hono project. This violates our Contribution Guide. Even so, this PR is good; let's proceed and think about it later.

@flow-pie
Copy link
Copy Markdown
Contributor Author

flow-pie commented Apr 2, 2026

@exoego Thank you for your comments and follow-up.

@flow-pie As @usualoma said, what you did hurt some, and it is not acceptable in our Hono project. This violates our Contribution Guide. Even so, this PR is good; let's proceed and think about it later.

I get your point and will be more mindful of community boundaries moving forward.

@yusukebe
Copy link
Copy Markdown
Member

yusukebe commented Apr 2, 2026

@flow-pie Thank you for understanding. If the code is ready for reviewing, please ping us!

Refine classNameSlug implementation by replacing sanitization with
strict validation and adding an onInvalidSlug callback.

- Add isValidClassName and isValidKeyframeName for strict identifier
  checks (^?-?[_a-zA-Z][_a-zA-Z0-9-]*$).
- Reject CSS-wide reserved keywords for @Keyframes animation names.
- Pre-normalize labels (trim + hyphenate) before passing to classNameSlug.
- Add optional onInvalidSlug callback to createCssContext for custom
  error handling (defaults to console.warn).
- Ensure parity between SSR and DOM-side createCssContext.
- Ensure the default path remains zero-overhead when classNameSlug is unused.
@flow-pie
Copy link
Copy Markdown
Contributor Author

flow-pie commented Apr 2, 2026

@flow-pie Thank you for understanding. If the code is ready for reviewing, please ping us!

@yusukebe , @usualoma the PR is ready for review. I'll be more than happy to adjust it further if needed, otherwise feel free to take it from here.

Comment thread src/jsx/dom/css.test.tsx Outdated
Copy link
Copy Markdown
Member

@yusukebe yusukebe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@yusukebe
Copy link
Copy Markdown
Member

yusukebe commented Apr 3, 2026

Looks good to me! @usualoma Can you approve?

@usualoma
Copy link
Copy Markdown
Member

usualoma commented Apr 3, 2026

@flow-pie Thank you!

I think we can merge it now, too!

@yusukebe
Copy link
Copy Markdown
Member

yusukebe commented Apr 4, 2026

@usualoma Thanks!

I'll merge this PR immediately. This change is a new feature, but slight, so I will release a patch version with this feature. Thank you!

@yusukebe yusukebe merged commit f82aba8 into honojs:main Apr 4, 2026
20 checks passed
@flow-pie
Copy link
Copy Markdown
Contributor Author

flow-pie commented Apr 4, 2026

@flow-pie Thank you!

I think we can merge it now, too!

Thanks for your review and guidance!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Customization of generated CSS classname

4 participants