Reference

API reference

Look up supported class inputs, variant types, defaults, and customization signatures in @hulla/style.

On this page

Look up the inputs accepted by cn(), the types inferred by vn(), and the options for style(). For a worked example, follow Style a button.

TypeScript
import { style } from '@hulla/style'

const { cn, vn } = style()

Both configuration properties are optional. serializer defaults to defaultSerializer; composer defaults to defaultComposer. The returned cn and vn share the same configured pipeline. style() does not generate CSS or know framework-specific class props.

ClassName is a non-recursive class input or a conditional object. The source type is ClassNameNonRecursive | Record<string, ClassNameNonRecursive>; ClassNameNonRecursive is an internal helper, not a public export:

TypeScript
import type {
  ClassName,
  Composer,
  Serializer,
  StyleConfig,
  Variant,
  VariantsDefinition,
} from '@hulla/style'

ClassName accepts strings, string[], numbers, booleans, nullish values, 0n, and conditional objects. Arrays must contain strings: nested arrays and boolean array members are outside the declared type.

The default serializer produces these values at runtime:

InputSerialized output
'base px-2''base px-2'
['base', 'px-2']'base px-2'
{ active: true, hidden: false }'active'
new Set(['base', 'px-2'])'base px-2'
new Map([['base', 'active']])'base active' (entry key and value are serialized)
'', 0, 0n, NaN, false, null, undefined''
Any non-zero number, such as 42Stringified numeric text
1n'' (bigint values are not serialized)
true''
Some runtime inputs aren’t accepted by TypeScript

The serializer handles Set and Map, but the public ClassName type doesn’t include them. Prefer string arrays and conditional objects in typed code. Non-zero bigints such as 1n are also excluded; at runtime all bigints serialize to an empty string.

cn(...classes) serializes each argument, then passes the resulting strings to the composer. With the default composer, empty inputs disappear and repeated class names appear only once, in their original order:

TypeScript
cn('rounded px-2', 'px-2 text-sm') // 'rounded px-2 text-sm'
cn(42, NaN) // '42'
cn('px-2', 'px-4') // 'px-2 px-4'

Exact-token deduplication is not CSS conflict resolution. defaultComposer keeps both different spacing classes. Configure a utility-aware composer such as twMerge if the later conflicting utility should win; cn itself does not interpret CSS.

vn(variants) maps each key to a class string. Its return type, Variant<V>, is a callable function with an .infer property typed as the keys of your map:

TypeScript
const $size = vn({ compact: 'px-2 text-sm', roomy: ['px-4', 'text-base'] })
type Size = typeof $size.infer // 'compact' | 'roomy'
const classes = $size('compact')

.infer exists for TypeScript and is undefined at runtime. Call the variant function to get classes.

There is no default selection in vn(). Set a default in your component’s props when callers can omit the choice. Variant classes use the same serializer and composer as cn().

The exported signatures are Serializer = (input: ClassName) => string and Composer = (...strings: string[]) => string; both are synchronous. This runnable example imports the default serializer and changes each input to uppercase before a simple joining composer receives it:

TypeScript
import { defaultSerializer, style } from '@hulla/style'

const { cn } = style({
  serializer: (input) => defaultSerializer(input).toUpperCase(),
  composer: (...strings) => strings.filter(Boolean).join(' '),
})

cn('base', { active: true }) // 'BASE ACTIVE'

A custom serializer receives every cn argument, including strings, and runs on variant input too. Return one string per input position.

A custom composer receives those strings in order and decides how to combine them: preserve duplicates, normalize whitespace, or resolve conflicting utilities. Both hooks are synchronous.

VariantsDefinition describes a variant map, Variant<V> types its callable function and .infer, and StyleConfig holds the two hooks. See Custom merging for a Tailwind example.

The package exports style(config?: StyleConfig), defaultSerializer(input: ClassName): string, defaultComposer(...strings: string[]): string, and the public types ClassName, Serializer, Composer, VariantsDefinition, StyleConfig, and Variant. The Variant<V> shape is ((prop: keyof V) => string) & { infer: keyof V }.