Skip to content

Instantly share code, notes, and snippets.

@diegohaz
Last active January 6, 2026 11:55
Show Gist options
  • Select an option

  • Save diegohaz/0a6ee32ff076e0d13bd94fa0f2ee2769 to your computer and use it in GitHub Desktop.

Select an option

Save diegohaz/0a6ee32ff076e0d13bd94fa0f2ee2769 to your computer and use it in GitHub Desktop.

Clava

CVA alternative focused on style composition.

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
  },
});

Computed 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 });
  },
});

Inline styles

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,
  },
});

Applying styles to framework components

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, astro

In addition to that, foo() and foo.style() can be configured to return a default shape (jsx, html, or htmlObj).

Configuration

import { create } from "clava";

export const { cv, cx } = create({
  defaultMode: "htmlObj",
  transformClass: twMerge,
});

Scoped CSS

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>
  );
}

The $ prefix

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 $prop is easy to recognize as a style prop and helps avoid confusion.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment