Styling made easy.
A tiny, zero-dependency styling utility focused on two primitives:
cn(...)for class compositionvn(...)for flat variant maps
- β
Tiny runtime -
709Bsmallest out there - β Works out of the box - sane defaults with no setup required
- β Framework agnostic - works in React, Astro, Vue, Svelte, and plain TS/JS
- β
Composable architecture - pair with
clsx,tailwind-merge, or your own composer - β Fully typesafe variants - auto-infer component props, enforce valid variant keys, and validate params at call sites
| Feature | @hulla/style | clsx/classnames | cva | tailwind-variants |
|---|---|---|---|---|
| Class composition | β | β | β | β |
| Variant maps | β | β | β | β |
| Works with custom composers | β | β | β | β |
| Object/array input support in composer | β | β | Limited | Limited |
| Type-safe variant keys | β | β | β | β |
| Bundle size | 0.7KB | ~1KB | ~2.5KB | ~5KB |
| Framework/styling agnostic | β | β | β | β |
pnpm add @hulla/style
# or npm/yarn/bun/denoCreate one shared style module and import from it everywhere.
// src/lib/style.ts
import { style } from "@hulla/style"
export const { cn, vn } = style()
// --- Or you can also use it with more advanced composers like ---
export const { cn, vn } = style({ composer: twMerge })The cn utility is a light-weight class composer you're familiar from packages like clsx or twJoin
import { cn } from "@/lib/style"
const className = cn(
"inline-flex items-center",
["px-4", "py-2"],
{ "opacity-80": true, hidden: false },
)
// => "inline-flex items-center px-4 py-2 opacity-80"Tip
You don't need to use tailwind. You're free to vanilla css or any other approach. Check the examples section
The vn utility is useful for definition various style combinations. The benefit is, it uses the defined style composer for config, so it leads to consistent and optimized results with your cn unlike other utilities like cva and
on top of that provides full typesafet for your props through .infer
import { vn } from "@/lib/style"
const $size = vn({
sm: "text-sm",
md: "text-base",
lg: "text-lg",
})
$size("sm")
// => "text-sm"
$size("lg")
// => "text-lg"Note
The $prop naming convention is not mandatory but recommended. Helps to distinguish between what's
a vn function and what's your prop you use from a component. i.e. ({ size }: Props) => $size(size)
type Size = typeof $size.infer
// => "sm" | "md" | "lg"
// or in your component example
type Props = {
size: typeof $size.infer
}import { cn, vn } from "@/lib/style"
const $size = vn({
sm: "text-sm p-2",
md: "text-base p-4",
lg: "text-lg p-6",
})
const $variant = vn({
primary: "text-primary bg-blue-500",
danger: "text-danger bg-red-500",
})
type Props = {
size?: typeof $size.infer
variant: typeof $variant.infer
}
function Button({ variant, size = "md" }: Props) {
return (
<button
className={cn(
"rounded border hover:opacity-80", // shared base classes
$size(size),
$variant(variant),
)}
/>
)
}
// <Button variant="danger" />
// => "rounded border hover:opacity-80 text-base p-4 text-danger bg-red-500"
// ^ base class ^ default size "md" ^ variant "danger"Also notice how we defined a default for $size making size?: typeof $size.infer optional parameter and in the component ({ size = "md" }) defining default value for it. This way we can define defaults for each component
Meanwhile the variant is mandatory (no ?:) so if user tried <Button /> they you would get Mandatory type "variant" is missing in type "Props" type-error.
This way we get full type safety and control over if each variant needs to be specified and what default values it should use.
The @hulla/style package works out of the box with just export const { cn, vn } = style(), but you can fully
customize it to your liking keeping consistent behaviour accross your entire codebase.
// src/lib/style.ts
import { style } from "@hulla/style"
import { twMerge } from "tailwind-merge"
export const { cn, vn } = style({
// serializer: ... <- you can also pass a custom serializer here
composer: twMerge,
})- The
serializerparses what gets passed into yourcnandvncalls (usually stuff like parsing objects, arrays, etc) - The
composertransforms theserializeroutput
Default behavior:
- serializer supports strings, arrays, nested arrays, and objects, sets and maps
- default composer dedupes exact duplicate class tokens
- Keep vn declarations flat and focused (
$size,$variant,$state) to avoid accidental class overrides. - Use JS function param defaults (
size = "md") for defining variant defaults - Put shared classes in one
cn("base here", $size(size))base string instead of withinvndefinitions. - Use
$-prefixedvnvariables to avoidsize(size)naming collisions.
styledefaultSerializerdefaultComposer- types from
types.public.ts