CVA alternative focused on style composition.
It should let us create small utilities like border that other styles can reuse.
Note
Prefixing variants with $ is not required, but it will help later when working with framework components.
See The $ prefix.
import { cv } from "clava";
export const border = cv({
variants: {
$border: {
adaptive: "ak-edge/0",
light: "ak-edge/5",
true: "ak-edge",
medium: "ak-edge/20",
bold: "ak-edge/40",
},
$adaptiveRing: {
true: "...",
},
},
defaultVariants: {
$border: false,
$adaptiveRing: false,
},
});
export const button = cv({
extend: [border],
class: "...",
variants: {
$kind: {
flat: "...",
classic: "...",
},
$variant: {
default: "...",
primary: "...",
},
},
defaultVariants: {
$kind: "flat",
$variant: "default",
$border: true, // can override extended variants
},
});It should let users implement all the styling logic without touching framework components, unless they need to change the markup. For that, working only with a serializable config object isn’t enough. We need functions to create computed variants:
export const border = cv({
variants: {
$border: { ... },
$adaptiveRing: { ... },
},
defaultVariants: { ... },
computed(variants, config) {
if (!variants.$border) return;
config.class(variants.$adaptiveRing ? "ak-bordering" : "ak-border");
},
});Variant definitions can be functions:
export const border = cv({
variants: {
$border: { ... },
$adaptiveRing(value: boolean) {
return value ? "ak-bordering" : "ak-border";
},
},
computed(variants, config) {
if (!variants.$border) {
config.variants({ $adaptiveRing: false });
}
},
});
export const command = cv({
extend: [border],
variants: {
$depthX(value: number) {},
$depthY(value: number) {},
$depth(value: number) {},
},
defaultVariants: {
$depth: 5,
},
computed(variants, config) {
config.defaultVariants({ $depthX: variants.$depth, $depthY: variants.$depth });
},
});Sometimes a component needs inline styles to apply dynamic CSS properties. Because we want all component styling logic encapsulated here, we need to support that:
export const foo = cv({
class: "border-(--foo)",
style: {
backgroundColor: "red",
},
variants: {
$foo(value: number) {
return { "--foo": `${value}px` };
},
},
defaultVariants: {
$foo: 10,
},
});The issue with supporting inline styles is that we can’t just return a class string that can be passed to the class or className props. Instead, we need to return a { class, style } object, but different frameworks expect different property shapes. React, for example, expects { className, style }, and style must be an object. Other frameworks accept { class, style }, where style can be either an object or a string. And in plain HTML, style has to be a string.
To handle these differences, we'll add methods to the style component object that split props and return the right props for each framework:
interface FooProps extends ComponentProps<"div">, VariantProps<typeof foo> {};
function Foo(props: FooProps) {
// With this, we don't need to update the framework component if we add a new variant.
// variantProps will include className, style, and merge them into the final styles.
const [variantProps, rest] = foo.jsx.splitProps(props);
return <div {...rest} {...foo.jsx(variantProps)} />;
}Methods:
foo.jsx(); // { className: "", style: { backgroundColor: "red" } } → react
foo.html(); // { class: "", style: "background-color: red;" } → html, vue, solid-js, astro, etc.
foo.htmlObj(); // { class: "", style: { "background-color": "red" } } → solid-js, astro
foo.class(); // ""
foo.jsx.style(); // { backgroundColor: "red" } → react, astro
foo.html.style(); // "background-color: red;" → html, vue, solid-js, astro, etc.
foo.htmlObj.style(); // { "background-color": "red" } → solid-js, astroIn addition to that, foo() and foo.style() can be configured to return a default shape (jsx, html, or htmlObj).
import { create } from "clava";
export const { cv, cx } = create({
defaultMode: "htmlObj",
transformClass: twMerge,
});Supporting inline styles opens up an interesting possibility we can explore later. We could support CSS nesting, then transform it into a string and inject it into <style>@scope { ... }</style> (see https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope):
import type { VariantProps } from "clava";
export const foo = cv({
style: {
"&:hover": { ... },
},
});
interface FooProps extends ComponentProps<"div">, VariantProps<typeof foo> {};
function Foo(props: FooProps) {
const [variantProps, rest] = foo.jsx.splitProps(props);
return (
<div {...rest}>
<style dangerouslySetInnerHTML={{ __html: foo.html.scopedStyle(variantProps) }} />
</div>
);
}While not required, prefixing variants with $ has several benefits:
- It avoids conflicts with other component props. We should be able to update style components without touching framework components.
- When using framework components, users can type
$to see all available style props. - When using framework components, a
$propis easy to recognize as a style prop and helps avoid confusion.