Styling made easy π¨
A unified, type-safe styling library that works with any CSS framework or methodology.
@hulla/style is a tiny (~1KB), zero-dependency library that unifies class name composition with powerful variant management. Unlike other solutions, it works consistently across any composer (clsx, tailwind-merge, etc.) and provides first-class TypeScript support.
When building component libraries, you often need to:
- Compose class names conditionally
- Define component variants (sizes, colors, states)
- Combine multiple variants together
- Use different CSS frameworks (Tailwind, vanilla CSS, CSS modules)
- Ensure type safety for all variants
Most libraries solve only part of this puzzle, forcing you to combine multiple tools or compromise on features.
@hulla/style provides a unified API that:
- β Works with any composer - Use with clsx, tailwind-merge, or vanilla strings
- β Handles complex types - Objects, arrays, nested structures work everywhere
- β Type-safe variants - Get autocomplete and type checking for all variants
- β Composable architecture - Mix variants, groups, and raw strings seamlessly
- β Framework agnostic - Works with React, Vue, Astro, Svelte, or plain HTML
- β Zero dependencies - Tiny bundle size, no external deps required
- β Extensible - Customize serialization and composition behavior
| Feature | @hulla/style | clsx/classnames | cva | tailwind-variants |
|---|---|---|---|---|
| Class composition | β | β | β | β |
| Variant management | β | β | β | β |
| Variant groups | β | β | β | Limited |
| Object syntax support | β Everywhere | β Only cn | β | β |
| Works with any composer | β | N/A | β tw only | β tw only |
| Customizable serialization | β | β | β | β |
| Bundle size | ~1KB | ~1KB | ~2.5KB | ~5KB |
| TypeScript support | β Full | Partial | β Full | β Full |
| Framework agnostic | β | β | β | β React only |
npm install @hulla/style
# or
pnpm add @hulla/style
# or
yarn add @hulla/style
# or
bun add @hulla/styleimport { style, type VariantProps } from '@hulla/style'
// Create your style utilities
const { cn, variant, variantGroup } = style()
// Use cn for simple class composition
const buttonClass = cn('px-4 py-2', 'rounded', 'bg-blue-500')
// => "px-4 py-2 rounded bg-blue-500"
// Define variants for reusable component styles
const button = variant({
name: 'variant',
classes: {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-500 text-white',
},
base: 'px-4 py-2 rounded font-semibold',
default: 'primary'
})
button.css() // => "px-4 py-2 rounded font-semibold bg-blue-500 text-white"
button.css('secondary') // => "px-4 py-2 rounded font-semibold bg-gray-500 text-white"
type Props = VariantProps<typeof button> // { variant?: 'primary' | 'secondary' }The cn function composes class names, supporting strings, arrays, objects, Sets, and Maps:
const { cn } = style()
// Strings
cn('foo', 'bar') // => "foo bar"
// Arrays
cn(['foo', 'bar']) // => "foo bar"
// Objects (keys with truthy values)
cn({ foo: true, bar: false, baz: true }) // => "foo baz"
// Mixed
cn('base', ['hover:bg-blue'], { active: true, disabled: false })
// => "base hover:bg-blue active"
// Nested
cn('base', ['text-lg', { bold: true, italic: false }])
// => "base text-lg bold"Variants define reusable component styles with different states:
const button = variant({
name: 'size',
classes: {
sm: 'text-sm px-2 py-1',
md: 'text-base px-4 py-2',
lg: 'text-lg px-6 py-3',
},
base: 'rounded font-semibold transition-colors',
default: 'md'
})
button.css('sm') // => "rounded font-semibold transition-colors text-sm px-2 py-1"
button.css('md') // => "rounded font-semibold transition-colors text-base px-4 py-2"
button.css() // => "rounded font-semibold transition-colors text-base px-4 py-2" (default)const button = variant({
name: 'variant',
classes: {
primary: ['bg-blue-500', 'text-white', 'hover:bg-blue-600'],
secondary: ['bg-gray-500', 'text-white', 'hover:bg-gray-600'],
},
default: 'primary'
})const button = variant({
name: 'state',
classes: {
active: { 'bg-blue-500': true, 'text-white': true, 'opacity-50': false },
disabled: { 'bg-gray-300': true, 'cursor-not-allowed': true },
},
default: 'active'
})import type { VariantProps } from '@hulla/style'
const button = variant({
name: 'variant',
classes: {
primary: 'bg-blue-500',
secondary: 'bg-gray-500',
},
default: 'primary'
})
type ButtonProps = VariantProps<typeof button>
// ButtonProps = { variant?: 'primary' | 'secondary' }
function Button({ variant }: ButtonProps) {
return <button className={button.css(variant)} />
}Combine multiple variants for more complex component APIs:
const size = variant({
name: 'size',
classes: {
sm: 'text-sm px-2 py-1',
md: 'text-base px-4 py-2',
lg: 'text-lg px-6 py-3',
},
default: 'md'
})
const variant = variant({
name: 'variant',
classes: {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-500 text-white',
danger: 'bg-red-500 text-white',
},
default: 'primary'
})
const buttonStyles = variantGroup(size, variant)
// Use with defaults
buttonStyles.css({})
// => "text-base px-4 py-2 bg-blue-500 text-white"
// Override specific variants
buttonStyles.css({ size: 'lg', variant: 'danger' })
// => "text-lg px-6 py-3 bg-red-500 text-white"
// TypeScript support
type ButtonProps = VariantProps<typeof buttonStyles>
// ButtonProps = { size?: 'sm' | 'md' | 'lg', variant?: 'primary' | 'secondary' | 'danger' }Mix cn, variants, and variant groups seamlessly:
const { cn, variant, variantGroup } = style()
const size = variant({
name: 'size',
classes: { sm: 'text-sm', lg: 'text-lg' },
default: 'sm'
})
const color = variant({
name: 'color',
classes: { blue: 'text-blue-500', red: 'text-red-500' },
default: 'blue'
})
const styles = variantGroup(size, color)
// Compose with additional classes
const finalClass = cn(
'base-class',
styles.css({ size: 'lg', color: 'red' }),
'hover:opacity-80',
{ active: true }
)
// => "base-class text-lg text-red-500 hover:opacity-80 active"Use @hulla/style with your preferred class name library:
import { style } from '@hulla/style'
import { twMerge } from 'tailwind-merge'
import { clsx } from 'clsx'
// With tailwind-merge (handles Tailwind class conflicts)
const { cn, variant, variantGroup } = style({ composer: twMerge })
// With clsx
const { cn, variant, variantGroup } = style({ composer: clsx })
// Objects, arrays, and nested structures work with ANY composer!
cn({ 'text-blue-500': true, 'bg-white': false }, ['px-4', 'py-2'])Override how class names are serialized:
import { style, defaultComposer } from '@hulla/style'
const { cn, variant, variantGroup } = style({
serializer: (input) => {
// Custom logic to convert input to string
if (typeof input === 'string') return input
// ... your custom serialization
return ''
},
composer: defaultComposer
})For more explicit APIs, create variants without defaults:
const button = variant({
name: 'variant',
classes: {
primary: 'bg-blue-500',
secondary: 'bg-gray-500',
},
// No default specified
})
// TypeScript enforces passing a variant
button.css('primary') // β
OK
button.css() // β TypeScript error: prop is requiredimport { style } from '@hulla/style'
import type { VariantProps } from '@hulla/style'
import { twMerge } from 'tailwind-merge'
const { cn, variant, variantGroup } = style({ composer: twMerge })
const buttonSize = variant({
name: 'size',
classes: {
sm: 'text-sm px-3 py-1.5',
md: 'text-base px-4 py-2',
lg: 'text-lg px-6 py-3',
},
default: 'md'
})
const buttonVariant = variant({
name: 'variant',
classes: {
primary: 'bg-blue-500 hover:bg-blue-600 text-white',
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900',
danger: 'bg-red-500 hover:bg-red-600 text-white',
},
default: 'primary'
})
const buttonStyles = variantGroup(buttonSize, buttonVariant)
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonStyles>
export function Button({ size, variant, className, children, ...props }: ButtonProps) {
return (
<button
className={cn(
'rounded font-semibold transition-colors disabled:opacity-50',
buttonStyles.css({ size, variant }),
className
)}
{...props}
>
{children}
</button>
)
}
// Usage
<Button size="lg" variant="danger" className="custom-class">
Delete
</Button>---
import { style } from '@hulla/style'
import type { VariantProps } from '@hulla/style'
const { variant, variantGroup } = style()
const size = variant({
name: 'size',
classes: {
sm: 'text-sm px-2 py-1',
md: 'text-base px-4 py-2',
},
default: 'md'
})
const color = variant({
name: 'color',
classes: {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-500 text-white',
},
default: 'primary'
})
const buttonStyles = variantGroup(size, color)
type Props = VariantProps<typeof buttonStyles>
const props = Astro.props
---
<button class={buttonStyles.css(props)}>
<slot />
</button><script setup lang="ts">
import { style } from '@hulla/style'
import type { VariantProps } from '@hulla/style'
const { cn, variant, variantGroup } = style()
const size = variant({
name: 'size',
classes: {
sm: 'text-sm px-2 py-1',
md: 'text-base px-4 py-2',
},
default: 'md'
})
const buttonVariant = variant({
name: 'variant',
classes: {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-500 text-white',
},
default: 'primary'
})
const buttonStyles = variantGroup(size, buttonVariant)
type ButtonProps = VariantProps<typeof buttonStyles>
interface Props extends ButtonProps {
class?: string
}
const props = withDefaults(defineProps<Props>(), {})
const classes = computed(() =>
cn(
'rounded transition-colors',
buttonStyles.css({ size: props.size, variant: props.variant }),
props.class
)
)
</script>
<template>
<button :class="classes">
<slot />
</button>
</template>Creates style utilities with optional configuration.
const { cn, variant, variantGroup } = style({
serializer?: (input: ClassName) => string,
composer?: (...strings: string[]) => string
})Parameters:
config.serializer- Custom function to serialize class name inputs to stringsconfig.composer- Custom function to compose strings (e.g.,clsx,twMerge)
Returns:
cn- Function to compose class namesvariant- Function to create variantsvariantGroup- Function to create variant groups
Composes class names from various input types.
cn(
'string',
['array', 'of', 'strings'],
{ objectKey: boolean },
nestedStructures
)Creates a variant with multiple style options.
const myVariant = variant({
name: string, // Variant name (for variantGroup)
classes: Record<string, ClassName>, // Style definitions
base?: string, // Base classes applied to all variants
default?: keyof classes // Default variant (optional)
})
myVariant.css(key?) // Returns class string
myVariant.params // Access variant definitionCombines multiple variants into a single API.
const group = variantGroup(variant1, variant2, ...)
group.css(props) // Returns composed class string
group.params // Access all variant definitionsimport type { VariantProps, ClassName, Serializer, Composer } from '@hulla/style'
// Extract props type from variant or variant group
type Props = VariantProps<typeof myVariantOrGroup>MIT Β© Samuel Hulla
Contributions are welcome! Please check out our GitHub repository.