npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@teacss/classes

v0.4.3

Published

TeaCSS class composition — typed recipes, nested class-value joining, and semantic conflict resolution.

Downloads

2,469

Readme

@teacss/classes

Typed class composition and semantic merging for TeaCSS.

Purpose

This package joins nested class-value inputs and removes earlier TeaCSS tokens that are overridden by later tokens under the same condition sequence. The algorithm is independent of CSS generation and ships with no runtime dependencies: callers provide vocabulary-specific conflict metadata and optional merger plugins.

It is the low-level package for custom integration and merger authors. Applications and component libraries built on the official vocabulary use the pre-bound cn and recipe exports from teacss. This package does not generate CSS, validate whether a class value is supported, or own the Standard and icon vocabularies.

Recipe composition

Most applications should use recipe from teacss. It is pre-bound to the official standard-and-icon cn, so conflicts are resolved inside each recipe output.

Use a primitive string for the top-level className when a recipe styles one element. Variant choices and compound outputs are strings, and calling the recipe returns the merged string directly. The call also accepts caller className:

import { recipe, type ClassProp, type VariantProps } from "teacss";

const badge = recipe({
  className: "d:inline-flex align-items:center rd:full p-x:2",
  variants: {
    tone: {
      neutral: "bg-color:gray-100 text-color:gray-900",
      accent: "bg-color:blue-500 text-color:white",
    },
  },
  compoundVariants: [
    {
      tone: "accent",
      className: "font-weight:700",
    },
  ],
});

badge({
  tone: "accent",
  className: "p-x:3",
}); // "d:inline-flex align-items:center rd:full bg-color:blue-500 text-color:white font-weight:700 p-x:3"

type BadgeProps = VariantProps<typeof badge> & ClassProp;

function badgeClassName({ className, ...variants }: BadgeProps) {
  return badge({ ...variants, className });
}

Use a non-empty object for the top-level className when a recipe has multiple elements. There is no distinguished or required root key: every declared key is an ordinary named class and becomes a resolver in the result. Variant choices target those names with maps; compounds support a targeted map or a shared broadcast:

const button = recipe({
  className: {
    container: "d:inline-flex align-items:center p:2 p:4@md",
    icon: "inline-size:4x",
    label: "",
  },
  variants: {
    size: {
      small: {
        container: "p:2",
        icon: "inline-size:3x",
      },
      large: {
        container: "p:4",
        icon: "inline-size:5x",
      },
    },
    disabled: {
      true: {
        container: "opacity:50 pointer-events:none",
      },
    },
  },
  defaultVariants: {
    size: "small",
    disabled: false,
  },
  compoundVariants: [
    {
      size: "large",
      disabled: true,
      className: {
        container: "font-weight:700",
        icon: "inline-size:6x",
      },
    },
    {
      disabled: true,
      classKeys: ["container", "icon"],
      className: "opacity:80",
    },
  ],
});

const large = button({
  size: "large",
});

large.container({
  disabled: true,
  className: "p:6",
});
large.icon();
large.label();

type ButtonProps = VariantProps<typeof button> & ClassProp;

function buttonClassName({ className, ...variants }: ButtonProps) {
  const classes = button(variants);
  return classes.container({ className });
}

For an object-form recipe, the recipe call accepts only shared variant selections. Each resolver accepts all recipe selections plus ClassProp; its local selections and className apply only to that resolver invocation. Compounds are re-evaluated against that local selection without mutating the captured recipe result or sibling resolvers. A string-form recipe has no resolver layer, so its recipe call accepts className and returns a string.

Multi-element variant choices are always class-target maps. Each compound uses exactly one of two forms: a non-empty className target map, or a primitive-string className paired with a non-empty classKeys array that broadcasts the class to every listed effective named class. classKeys may contain declared or inherited class names and must be dense, duplicate-free, and free of unknown names. Matching entries contribute to the selected classes in compound declaration order. The two forms cannot be combined. classKeys is reserved as a variant name, while parts, styles, and styleKeys are available as ordinary variant names.

The field name className is intentionally reused only at distinct boundaries: top-level definition.className supplies base classes and selects string or object mode; compoundVariants[].className supplies a compound output; and caller className is accepted by a single-element recipe call or a named resolver. A multi-element recipe call does not accept caller className.

The one-sided disabled variant above is boolean-capable. true is branch-backed and emits its fragment; boolean false is a distinct branchless state that emits no variant fragment but remains available to defaults, resolver-local selections, and compound matching. The undeclared string "false" is not an alias for that branchless state.

Responsive and stateful behavior stays in static TeaCSS class values using suffix conditions such as p:4@md and bg-color:blue-600@hover; recipe props do not introduce responsive objects.

Recipes extend one parent created by the exact same reciper. An extending child may omit className to inherit the parent's string or object mode, base fragments, and existing named classes. If a child declares className, it must preserve that mode; an object child may add new names:

const primaryButton = recipe({
  extend: button,
  variants: {
    emphasis: {
      strong: {
        container: "font-weight:700",
      },
    },
  },
});

const strongBadge = recipe({
  extend: badge,
  className: "font-weight:700",
});

Typed definitions require one finite, statically stable schema. Optional, union-valued, or open index-signature key sets for object-form className, variants, or variant choices are rejected because they could promise resolvers or selections absent from the runtime branch. A string | object className value and a union-typed extend parent must be narrowed before calling recipe(). Finite required named interfaces are accepted without an artificial string index signature. __proto__ is reserved as a statically unsafe schema name because ordinary object-literal syntax does not create a predictable own property for it; an explicitly constructed null-prototype runtime record can still carry that own name safely.

Every definition without extend requires its own className field. An explicitly declared object className value must contain at least one key. The retired top-level definition field styles and former nested compound selector parts are not compatibility aliases; broadcast compounds use compoundVariants[].classKeys.

Reciper creation

@teacss/classes exports the createReciper factory and no pre-bound reciper. Each integration binds its own merger once and exports the result under the name appropriate for that integration:

import { createReciper, createMerger } from "@teacss/classes";

export const merge = createMerger({ conflicts, plugins });
export const recipe = createReciper({ merge });

export const libraryButton = recipe({
  className: "p:2",
});

createReciper accepts the required merge field; there is no default merger and no merger alias. Extension identity belongs to the exact reciper function, not merely to the merger. Its return type is the type-only Reciper export, so an integration can re-export its reciper without inlining the complete generic recipe contract into generated declarations.

Libraries that export official recipes for downstream extension peer-depend on and externalize teacss, which owns the official reciper. A custom integration peer-depends on @teacss/classes and exports the exact reciper used to create its parents. Consumers deduplicate that owning integration. Recreating a reciper, loading another physical or versioned runtime, or loading the parent and reciper from different provider-package instances breaks extension identity even when declarations and merger behavior match. Deduplicating only @teacss/classes does not repair a duplicated custom-reciper provider. VariantProps extraction is type-only and needs no runtime identity; only extend has this exact-identity requirement.

Class merging

bun add @teacss/classes
import { createMerger } from "@teacss/classes";

const cn = createMerger({
  conflicts: {
    keywords: {
      flex: "display",
      hidden: "display",
    },
    overlaps: {
      p: ["padding-top", "padding-right", "padding-bottom", "padding-left"],
      "p-x": ["padding-left", "padding-right"],
    },
  },
});

cn("p:4 flex", "p:8"); // "flex p:8"
cn("p:4", "p-x:8"); // "p:4 p-x:8"
cn("p:4", "p-x:8!"); // "p:4 p-x:8!"
cn('content:["hello world"]', 'content:["good bye"]'); // 'content:["good bye"]'

Attached [] literal regions keep their internal whitespace in one token and protect braces and semicolons from group expansion. Braces inside attached () CSS functions are likewise not mistaken for groups. Top-level {p:4;m:2}@hover group syntax still expands normally.

For the standard TeaCSS vocabulary, use the pre-bound merger instead:

import { cn } from "@teacss/preset-standard/merge";

Merger API

createMerger(options?) returns a callable Merger that accepts strings, mutable or readonly nested arrays and tuples, conditional class dictionaries, falsy values, numbers, and tagged templates. It preserves the order of surviving tokens; it does not sort them. Finite named interfaces are valid class dictionaries and conflict tables; runtime protocol validation still checks every supplied conflict atom.

Tagged-template literal segments and resolved expressions concatenate exactly. Put whitespace in the template wherever two class tokens need a boundary:

cn`p:${0}`; // "p:0"
cn`p:4@${"hover"}`; // "p:4@hover"
cn`${"p:4"} ${"m:2"}`; // "p:4 m:2"

Options:

  • conflicts: keyword and shorthand/longhand conflict footprints.
  • overlap: "precise" by default, or "symmetric" for last-intersection wins between equal-importance tokens. Differing importance still requires full winner coverage.
  • plugins: ordered, uniquely named plugins with optional static conflicts, token transforms, dynamic groups, drop-only finalizers, and pair resolvers.
  • cache: one input LRU shared by normal and tagged-template calls, or false to disable caching. cache.max defaults to 1500 and must be a finite non-negative integer; 0 disables caching.

Each static conflict value is one atom or a readonly atom array. Static metadata is composed once from top-level options and then plugins. Identical atom sets deduplicate; a different set for the same keyword or declaration key is a protocol error. Keyword and declaration keys are separate namespaces. Shared CSS atoms use CSS property names; a third-party private atom must begin with that plugin's unique name.

import {
  createMerger,
  MERGE_OPAQUE,
  type MergerPlugin,
} from "@teacss/classes";

const plugin: MergerPlugin = {
  name: "example-icons",
  conflicts: {
    keywords: {},
    overlaps: { icon: "example-icons:icon" },
  },
  group(token) {
    if (token.keyword || token.prefix !== "icon") return null;
    return token.value?.includes("-") ? null : MERGE_OPAQUE;
  },
};

const cn = createMerger({ plugins: [plugin] });

MERGE_OPAQUE is terminal classification. Different opaque raw strings survive; an exact duplicate keeps only its last occurrence and never enters finalizers or pair resolution. Import and return the named marker rather than raw false.

Token transforms receive a readonly token list and a run-local viewToken() function. Their result must be an array of individual non-empty class tokens; whitespace is valid only inside an attached [] literal region. Transform output is not split or group-expanded again. Dynamic group() hooks return atoms, MERGE_OPAQUE, or null. finalize() receives current non-opaque occurrences with stable ids and may only return ids to drop; null and [] both drop nothing. Every returned id must be an integer, unique, and present in that exact input. Core validates the whole result before applying any drop. resolve() may return "earlier", "later", "none", or null. Its context reports real full coverage in both directions through covers (later over earlier) and earlierCoversLater, independently of symmetric mode.

A resolver is global by default. A plugin may add resolveFamily(token) to scope it: the family hook runs once per non-opaque occurrence, and resolve() is considered only when both tokens return the same non-empty family. A resolveFamily hook requires a resolve hook. Use this metadata whenever a resolver owns a finite semantic family. A run made entirely of one unowned static prefix, such as standard p:*, can then use the linear winner path; dynamic groups and resolver-owned families still use pair resolution.

The package exports the MergerProtocolError class and the MergerProtocolErrorCode type. Errors expose code plus optional plugin and hook fields. Stable codes distinguish malformed options, cache bounds, conflict metadata, plugin names and hooks, static conflicts, and invalid transform, group, finalizer, resolver-family, or resolver results. Hook exceptions are propagated unchanged. A failed run writes no input-LRU entry.

Only own option, cache, conflict-shape, plugin-name, and plugin-hook fields are used; inherited prototype fields cannot alter a merger. Plugin arrays, own hook references, static records, and atom arrays are snapshotted when createMerger() runs. Array-shaped protocol results are validated from a fixed length and own numeric indexes; custom iterators and inherited sparse entries are not trusted. Every accepted array length must be an integer from 0 through 4294967294; malformed plugin, metadata, and hook arrays retain their surface-specific protocol codes, while malformed class-value and tagged-template arrays report INVALID_ARRAY_LENGTH. Hooks are called with this === undefined and should be pure. If a hook closes over mutable state, use cache: false.

Conflict Semantics

Only tokens with the same ordered condition sequence and intersecting atom footprints are candidates. When importance differs, the important token removes the non-important token only when its footprint fully covers the token being removed; partial overlaps survive so unrelated declarations are not lost. With equal importance, precise mode drops an earlier token only when the later footprint covers it; symmetric mode treats any intersection as coverage. Surviving tokens keep their original order. A custom resolve() hook remains an explicit override of the default verdict.

Merge never validates TeaCSS or CSS values. A structurally parsed declaration uses its declared or fallback footprint even when the value is unsupported or empty:

cn("p:4", "p:invalid"); // "p:invalid"
cn("p:4", "p:"); // "p:"

Run bun run bench:merger from the workspace root to measure the uncached standard static-prefix p:* path at 500, 1,000, 2,000, and 4,000 tokens.

Status

Pre-1.0. Public recipe and merge APIs may change before the stable release.