Skip to content
Twilight React Playground
ثيم رائدaren

normalizeClasses, mergeElementAttrs, toReactProps

functionAdvancedserverbrowserlive demo

The three steps of every html and body attribute merge: split class lists, merge attribute objects in order, rename class to className.

import { normalizeClasses, mergeElementAttrs, toReactProps } from '@salla.sa/twilight-theme-engine/utils';

In plain words

Each of these small functions does one step. normalizeClasses(' a b ') turns a class string, or an array of them, into a clean list: ['a', 'b']. mergeElementAttrs([first, second]) combines attribute objects for one element: classes add up, and other attributes take the last value. toReactProps(attrs) renames class to className so the result can be spread onto a JSX element.

mergeDocumentDescriptors runs the last two for <html> and <body>. Use these when you handle a single element.

Signature

function normalizeClasses(input: string | string[] | undefined): string[]

function mergeElementAttrs(descriptors: DocumentElementAttrs[]): Record<string, string>
// HTML names: class, data-*, aria-*…

function toReactProps(attrs: Record<string, string>): Record<string, string>
// class → className

Try it live

The three steps behind every html and body attribute merge: split classes, merge in order, rename for React.Try this: put extra spaces in the first class list, then empty the second role: an empty string never overrides an earlier value.
Storefront canvas · ar · RTL
normalizeClasses("  header   is-sticky ")
  → ["header","is-sticky"]

mergeElementAttrs([...])
  → {"role":"navigation","class":"header is-sticky has-banner"}

toReactProps(merged)
  → {"role":"navigation","className":"header is-sticky has-banner"}
Controls
What a theme writes
import { mergeElementAttrs, toReactProps } from '@salla.sa/twilight-theme-engine/utils';

const attrs = toReactProps(
  mergeElementAttrs([
    { class: '  header   is-sticky ', role: 'banner' },
    { class: ['is-sticky', 'has-banner'], role: 'navigation' },
  ])
);

export function Banner() {
  return <header {...attrs}>Free delivery this week</header>;
}

Example

app/components/Section.tsx
import {
  mergeElementAttrs,
  toReactProps,
  type DocumentElementAttrs,
} from '@salla.sa/twilight-theme-engine/utils';

export function Section({ attrs, children }: { attrs?: DocumentElementAttrs; children: React.ReactNode }) {
  // Defaults first, so a caller's role replaces ours and its classes add to ours.
  const props = toReactProps(mergeElementAttrs([{ class: 'section py-8', role: 'region' }, attrs ?? {}]));
  return <section {...props}>{children}</section>;
}

How it behaves

  • normalizeClasses splits every string on whitespace and drops empty names. It keeps duplicates; mergeElementAttrs removes them.

  • mergeElementAttrs skips undefined values and empty strings. The merged class is joined with single spaces and added as the last key, and only when there is at least one class.

  • DocumentClassProvider, useDocumentClass, attrs() from @salla.sa/twilight-theme-engine/dom and the TanStack and Next.js adapters all merge with mergeElementAttrs.

Gotchas

  • toReactProps renames class only. for, tabindex and http-equiv pass through unchanged, and React logs Invalid DOM property for them in development. Write the React names (htmlFor, tabIndex) in your attribute objects, or stick to class, data-* and aria-*.

  • Arrays count only for class: { role: ['banner'] } is dropped without a warning.

Related

Source and docs