Styling

@sometic/styling resolves classes, inline styles, CSS variables, slots, and stable data-* state attributes without depending on Tailwind, Bootstrap, or a CSS-in-JS runtime.

Overview

ModuleImportPurpose
Class resolver@sometic/styling or @sometic/styling/classesFlatten / merge class values
Style / CSS variables@sometic/styling or @sometic/styling/stylesMerge style objects and --* vars
Styleable compose@sometic/stylingresolveStyleableLayered class + style composition
Slots@sometic/styling/slotsNamed part contracts and attributes
State attributes@sometic/styling/stateStable data-* for disabled, invalid, …
Polymorphic as@sometic/styling/polymorphicElement swapping without framework Slot

When to use

Building headless or lightly styled components that must work with the consumer’s CSS system (utility classes, CSS Modules, tokens, plain CSS).

When not to use

  • Theme tokens, theme store, system preference, CSS variable generation from a theme@sometic/theme
  • Framework-specific asChild / Slot composition → framework adapters
  • Runtime Tailwind or Bootstrap plugins inside Sometic packages: never; pass class names only

Installation

pnpm
pnpm add @sometic/styling
npm
npm install @sometic/styling
yarn
yarn add @sometic/styling
bun
bun add @sometic/styling

Usage

Resolve styleable layers

ts
import { resolveStyleable } from "@sometic/styling";

const { className, style } = resolveStyleable({
    defaults: { className: "sometic-btn" },
    variants: { className: "sometic-btn--md" },
    state: { className: "is-disabled" },
    user: { className: "my-btn" },
    cssVariables: { "sometic-btn-pad": "0.75rem" },
});

Classes and Tailwind merge (optional)

ts
import { createClassResolver, resolveClasses } from "@sometic/styling/classes";
import { twMerge } from "tailwind-merge";

resolveClasses("a", ["b", false && "c"], { d: true });

const cx = createClassResolver({
    merge: (tokens) => twMerge(tokens.join(" ")),
});
cx("p-2", "p-4"); // consumer merger wins conflicts

Slots and state attributes

ts
import { defineSlots, createSlotAttributes } from "@sometic/styling/slots";
import { resolveStateAttributes } from "@sometic/styling/state";

const slots = defineSlots(["root", "label", "control"] as const);
const attrs = createSlotAttributes("control");

const stateAttrs = resolveStateAttributes({
    disabled: true,
    invalid: false,
    required: true,
});
// data-disabled="true", data-required="true", …

Polymorphic element

ts
import { resolvePolymorphicAs } from "@sometic/styling/polymorphic";

const tag = resolvePolymorphicAs({ as: "a" }); // "a"

Key APIs

ExportRole
resolveClasses / createClassResolverFlatten class values; optional merger
resolveStyles / resolveCssVariablesMerge styles; normalize --* keys
resolveStyleableLayered composition returning { className, style }
STYLE_OVERRIDE_PRIORITYDocumented layer order
StyleableProps<S>Shared prop contract (unstyled, classes, styles, cssVariables)
defineSlots / createSlotAttributes / pickSlotValueSlot contracts
resolveStateAttributes / STATE_ATTRIBUTE_KEYSStable state data-*
resolvePolymorphicAsResolve as element type

Override priority

resolveStyleable applies layers in this order (later style keys win; class tokens append left → right):

  1. Behavior-required
  2. Defaults (skipped when unstyled)
  3. Variants / size (skipped when unstyled)
  4. State-derived
  5. Consumer classes / styles
  6. Consumer cssVariables

How it works

Pure functions. No window, document, or CSSOM access at import time. Components stay unstyled by default; you pass class names or CSS variables from any styling system.

unstyled skips defaults and variants only. Behavior-required, state, user, and cssVariables still apply so accessibility hooks and consumer overrides remain.

Edge cases

EdgeBehavior
null style key in a layerDeletes that key from the merged style object
Boolean state attrsDefault value is "true"; pass { booleanValue: "" } for empty-string presence
Conflicting utility classesOnly resolved if you supply a merge function (for example twMerge)
asChildNot in this package; adapters own framework Slot composition

FAQ

Does this install Tailwind or Bootstrap?

No. Those stay consumer dependencies. Pass class names, or use createClassResolver({ merge }) with your own merger.

What does unstyled do?

Skips defaults and variants inside resolveStyleable. Behavior, state, user, and cssVariables still apply.

Why are state classes applied when unstyled?

So you can still style [data-disabled] / state hooks without shipping library visual defaults.

Where do design tokens live?

@sometic/theme. This package only merges consumer-provided CSS variables into style.

Is asChild supported here?

No. Use resolvePolymorphicAs for element swapping; adapters own framework-specific composition.

Bundle size?

Root entry targets ≤2KB gzip. Prefer subpath imports (/classes, /styles, /slots, /state, /polymorphic) when you need only one surface.