Every component in a Tailwind component library ends up with class strings that depend on props, and hand-written ternaries stop being readable after the second variant. tailwind-variants moves those classes into one typed definition. When you own every class in that definition, the tailwind-variants/lite build gives you the same API at 4.0 kB gzipped instead of 13.1 kB.
A Button without it
A button with three variants and three sizes, written by hand in Svelte 5:
<button
class="inline-flex items-center justify-center gap-2 rounded-md font-medium
{variant === 'solid' ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''}
{variant === 'outline' ? 'border border-border hover:bg-accent' : ''}
{variant === 'ghost' ? 'hover:bg-accent' : ''}
{size === 'sm' ? 'h-8 px-3 text-sm' : size === 'icon' ? 'size-10' : 'h-10 px-4'}
{variant === 'ghost' && size === 'icon' ? 'text-muted-foreground' : ''}
{className}"
>It works, but the prop types live somewhere else, the default size hides in the last branch of a ternary, and the ghost icon button is a condition you only find by reading every line. Add a fourth size and you touch the markup again.
The same Button with tailwind-variants
tv() takes the same classes as data and returns a function that builds the class string from props:
<script lang="ts" module>
import type { HTMLButtonAttributes } from "svelte/elements";
import { tv, type VariantProps } from "tailwind-variants/lite";
const button = tv({
base: "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:opacity-50",
variants: {
variant: {
solid: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-border hover:bg-accent",
ghost: "hover:bg-accent",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4",
icon: "size-10",
},
},
compoundVariants: [
{ variant: "ghost", size: "icon", class: "text-muted-foreground hover:text-foreground" },
],
defaultVariants: { variant: "solid", size: "md" },
});
type ButtonProps = HTMLButtonAttributes & VariantProps<typeof button>;
</script>
<script lang="ts">
// `class` is for layout (margin, width). It can't override variant classes.
let { variant, size, class: className, children, ...rest }: ButtonProps = $props();
</script>
<button class={[button({ variant, size }), className]} {...rest}>
{@render children?.()}
</button>The versions here are tailwind-variants 3.3.1, Svelte 5.57 and Tailwind CSS 4.3.
What you get
The definition reads like a spec. Each variant and its classes sit in one object, so you review a design change by reading that object, and adding a size means adding one key.
The prop types come from the definition. VariantProps<typeof button> turns the variant keys into a union, so a typo fails svelte-check:
Error: Type '"huge"' is not assignable to type '"outline" | "ghost" | "solid" | undefined'. (ts)Compound variants replace the variant === 'ghost' && size === 'icon' condition with an entry you can find by name, and defaultVariants makes the defaults explicit instead of the last branch of a ternary.
Components with several parts, like a card with a header and a body, use slots: one tv() call returns a class function per part, all driven by the same variants. extend builds one component on another, such as an icon button on top of the button.
tv() returns plain strings, so it doesn’t care about the framework. This site uses it in both a Svelte button and an Astro component.
What it costs
Classes are built at runtime. Svelte calls button() again whenever variant or size changes, and it joins the strings for the current props. tailwind-variants caches the result per prop combination, so the cost after the first call is small, but a static class="..." costs nothing.
The library ships to the browser. Bundled with the Button above, the default build comes to 13.1 kB gzipped and the lite build to 4.0 kB (details below).
Editor tools need setup. Tailwind CSS IntelliSense doesn’t complete classes inside tv() until you add the classRegex setting from the getting started page, and the Prettier plugin only sorts them if you list tv in tailwindFunctions.
It’s one more API for contributors to learn: base, variants, compoundVariants, slots, compoundSlots, extend. For a component with one look, a plain class string is easier to read than a tv() call.
Default vs lite
Both builds run the same variant engine and differ in how they join the final class list. The default build passes a merging adapter, backed by its own copy of a merger based on tailwind-merge. The lite build passes cx, which only concatenates. Its tv() takes the same options but drops the second config argument, which only configures merging.
Neither build needs the tailwind-merge package installed; it’s an optional peer dependency in 3.3.1.
I bundled the Button definition above with each import, using esbuild 0.28.2 (--bundle --minify --format=esm) and gzip -9:
| Import | Minified | Gzipped |
|---|---|---|
tailwind-variants | 40.7 kB | 13.1 kB |
tailwind-variants/lite | 11.2 kB | 4.0 kB |
The difference shows up when classes conflict. Here base sets px-4, the lg size sets px-6, and the caller passes px-8:
import { tv as full } from "tailwind-variants";
import { tv as lite } from "tailwind-variants/lite";
const opts = { base: "px-4 py-2", variants: { size: { lg: "px-6" } } };
console.log("full:", full(opts)({ size: "lg", class: "px-8" }));
console.log("lite:", lite(opts)({ size: "lg", class: "px-8" }));full: py-2 px-8
lite: px-4 py-2 px-6 px-8The default build keeps the last padding class. The lite build keeps all three, and which one wins depends on the order of the rules in the generated CSS, not on the order in the class attribute.
Why lite is enough for your own library
The merge step exists to fix conflicts, and in your own library you decide whether there are any. Give every CSS property one owner: if size sets padding and height, base doesn’t. The Button above follows that rule, and when I ran all 16 combinations of variant and size (including the defaults) through both builds, I got the same string every time. The class resolution docs suggest the same split: no-merge joining for “static strings with no overlap, or when you handle conflicts yourself.”
That leaves the class prop, the one place where classes come from outside the definition. I keep it for classes that add something no variant sets, like margin or width, and say so in a comment on the prop. Because lite would only concatenate className anyway, the component passes it to Svelte’s class array (Svelte 5.16 and later) instead of to button(). That also avoids a type mismatch, since Svelte’s class type accepts objects and arrays that tv() doesn’t.
Use the default build when you don’t control the callers, for example in a library other teams install and restyle with class. Switching later means changing the import and passing class to button() again, so the merger sees it.
Takeaway
- Move components with two or more variants to
tv(). Leave single-look components as plain class strings. - Derive prop types with
VariantProps<typeof x>instead of writing the unions by hand. - Import from
tailwind-variants/litewhen you own every class, and give each CSS property one owner in the definition. - Limit
classto margin and width, and document it on the prop. - Add the IntelliSense
classRegexand PrettiertailwindFunctionssettings with your firsttv()call.