@grainshape/fold
v0.0.2
Published
Type-safe class-variant utility library
Downloads
41
Maintainers
Readme
@grainshape/fold
Type-safe class variant resolver. Define component variants as a config object, get back a strongly-typed function that resolves the right class string at runtime.
Based on CVA by Joe Bell. Adds foldConfig() for lossless type preservation, a partition() method for framework-friendly prop splitting, and fixes several TypeScript inference issues present in CVA's type signatures.
Install
pnpm add @grainshape/foldIf you are using Tailwind and want automatic class conflict resolution, use @grainshape/tw-fold instead.
Basic usage
import { fold } from '@grainshape/fold'
const button = fold({
base: 'font-medium rounded',
variants: {
intent: {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-100 text-gray-900',
},
size: {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: {
intent: 'primary',
size: 'md',
},
})
button() // 'font-medium rounded bg-blue-500 text-white px-4 py-2'
button({ intent: 'secondary' }) // 'font-medium rounded bg-gray-100 text-gray-900 px-4 py-2'
button({ size: 'sm' }) // 'font-medium rounded bg-blue-500 text-white px-2 py-1 text-sm'
button({ class: 'w-full' }) // appends 'w-full' to the resolved stringCompound variants
Apply additional classes when a specific combination of variants is active.
const button = fold({
base: 'font-medium rounded',
variants: {
intent: {
primary: 'bg-blue-500',
secondary: 'bg-gray-100',
},
size: {
sm: 'text-sm',
lg: 'text-lg',
},
},
compoundVariants: [
// single value match
{ intent: 'primary', size: 'lg', class: 'uppercase tracking-wide' },
// array match — applies when size is 'sm' or 'lg'
{ intent: 'secondary', size: ['sm', 'lg'], class: 'border border-gray-300' },
],
})Compose
Merge multiple fold functions into one. All variant keys are combined; each component only receives the props it knows about.
import { fold, compose } from '@grainshape/fold'
const box = fold({
variants: {
shadow: { sm: 'shadow-sm', md: 'shadow-md', lg: 'shadow-lg' },
},
})
const stack = fold({
variants: {
gap: { 1: 'gap-1', 2: 'gap-2', 4: 'gap-4' },
direction: { row: 'flex-row', col: 'flex-col' },
},
})
const card = compose(box, stack)
card({ shadow: 'md', gap: 2, direction: 'col' })
// 'shadow-md gap-2 flex-col'The composed function is fully typed — shadow, gap, and direction are all inferred from their respective configs.
partition()
Every fold and compose result exposes a partition() method that splits an arbitrary props object into [variantProps, otherProps]. This is particularly useful in Svelte, where spreading unknown props onto a DOM element causes attribute warnings.
<!-- Button.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte'
import { fold, type VariantProps } from '@grainshape/fold'
const styles = fold({
base: 'font-medium rounded',
variants: {
intent: {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-100 text-gray-900',
},
size: { sm: 'px-2 py-1', md: 'px-4 py-2' },
},
defaultVariants: { intent: 'primary', size: 'md' },
})
type Props = VariantProps<typeof styles> & {
children?: Snippet
// any other props, e.g. native button attributes
[key: string]: unknown
}
let { children, ...props }: Props = $props()
// variantProps = { intent, size } (only what fold knows about)
// restProps = { onclick, disabled, ... } (everything else — reactive)
let [variantProps, restProps] = $derived(styles.partition(props))
</script>
<button class={styles(variantProps)} {...restProps}>
{@render children?.()}
</button>partition() is fully typed — variantProps contains only the known variant keys, restProps contains everything else, both inferred from the shape of the input.
cx()
A clsx-compatible utility, passed through any onResolve hook defined via defineConfig.
import { cx } from '@grainshape/fold'
cx('foo', undefined, false, 'bar') // 'foo bar'
cx({ active: true, disabled: false }) // 'active'foldConfig()
A typed identity function that captures a config's full generic type without instantiating a fold. This was one of the original motivations for building fold over CVA.
The problem with plain objects: if you define a config as a plain object literal and pass it around, TypeScript widens the type — you lose the exact variant key literals. foldConfig() preserves them.
// Without foldConfig — type is widened, variant keys are lost
const config = {
variants: { size: { sm: 'small', lg: 'large' } }
}
// typeof config.variants.size → Record<string, string> — 'sm' | 'lg' is gone
// With foldConfig — full type is preserved
import { foldConfig, fold } from '@grainshape/fold'
const buttonConfig = foldConfig({
base: 'font-medium rounded',
variants: {
intent: { primary: 'bg-blue-500', secondary: 'bg-gray-100' },
},
})
// typeof buttonConfig knows exactly: intent is 'primary' | 'secondary'
const button = fold(buttonConfig)
// button's props are still fully typed: { intent?: 'primary' | 'secondary' }This matters most when sharing configs across components or when using compose — each component's .config property is typed exactly, and compose.configs returns a typed tuple rather than a generic array:
const boxConfig = foldConfig({
variants: { shadow: { sm: 'shadow-sm', md: 'shadow-md' } },
})
const stackConfig = foldConfig({
variants: { gap: { 1: 'gap-1', 2: 'gap-2' } },
})
const box = fold(boxConfig)
const stack = fold(stackConfig)
const card = compose(box, stack)
// card.configs is typed as [typeof boxConfig, typeof stackConfig]
// — not a generic array, the exact tuple shape is preserved
type Configs = typeof card.configs
// [Readonly<FoldConfig<...boxConfig shape...>>, Readonly<FoldConfig<...stackConfig shape...>>]VariantProps
Extract the variant props type from a fold or compose function.
import { fold, type VariantProps } from '@grainshape/fold'
const button = fold({
variants: {
intent: { primary: '...', secondary: '...' },
size: { sm: '...', md: '...' },
},
})
type ButtonProps = VariantProps<typeof button>
// { intent?: 'primary' | 'secondary'; size?: 'sm' | 'md' }Custom resolver (defineConfig)
Use defineConfig to create a custom instance of fold, compose, and cx with a post-processing hook on the resolved class string. This is how @grainshape/tw-fold adds tailwind-merge support.
import { defineConfig } from '@grainshape/fold'
import { twMerge } from 'tailwind-merge'
const { fold, compose, cx, foldConfig } = defineConfig({
hooks: {
onResolve: (className) => twMerge(className),
},
})
export { fold, compose, cx, foldConfig }Credits
Built on top of the ideas and type patterns from CVA by Joe Bell. fold extends CVA's approach with foldConfig() for preserved generic types, partition() for prop splitting, and a defineConfig() factory for custom resolvers.
License
MIT © John van Dijk
This package contains code derived from CVA by Joe Bell (Apache 2.0). See NOTICE for details.
