Skip to content

Search

Start typing to search.


Show all pages

Documentation

Principles

The layered model under Calaca UI. Tokens, core, skin, modifiers and instance utilities each own one kind of property, and tests keep the borders closed.

Five layers, one question each

Calaca UI is five layers stacked on purpose. Each layer answers exactly one question:

  1. Tokens (--ca-*): what is the raw material? Color, radius, type, weight, elevation, motion and density. Tokens hold values, never rules.
  2. Core (src/core): how is the component built? Display, position, the focus ring, the disabled state, the touch target. The core also neutralizes browser defaults, because prebuilt CSS ships without a reset. It carries no visual opinion: the bare skeleton is usable and ugly on purpose.
  3. Skin (skins/*.ts): how does it look? Radius, border, shadow, height, padding, type, color and motion. A skin is data, so a skin that extends another one lists only what differs.
  4. Modifiers (ca-button--primary, ca-card--md): which state or shape? A modifier sets variables (pigments, gaps, sizes); it never paints.
  5. Instance utilities (mt-4, h-12, rounded-none): what is different right here, once? Tailwind utilities on the element itself.

Who wins

Later layers beat earlier ones. An instance utility beats a modifier, a modifier beats the skin, the skin beats the core, and the core beats the browser. ca-button ca-button--md h-12 renders at h-12.

The guarantee does not rest on source order, specificity tricks or !important. Each layer sets properties no other layer sets: the core owns display, position, outline and touch-action; the skin owns border-radius, background and height; a modifier owns its variables. When two layers can never collide, you never have to ask which one wins.

If you find yourself fighting the cascade, something is on the wrong layer.

State is data, not classes

Interactive state lives on attributes, not on extra classes: data-status, data-skin, data-appearance, open, aria-expanded, :focus-visible. A stepper item reads data-status="complete" and paints the check; a region reskins under [data-skin].

The split is honest on both sides: classes describe how an element looks, attributes describe what it is. Styling and behavior read the same attribute, so they can never disagree about whether a dialog is open.

Composition over components

Layout belongs to the page, not to the component. ca-container, ca-stack, ca-cluster, ca-grid and ca-section read their spacing from --ca-*-gap and --ca-*-max variables, and they wrap by intrinsic sizing: a cluster falls onto the next line whenever it runs out of room, so layouts need no media queries and fit any container they land in.

Components never reach outside their own box for positioning. The space between components is always the layout’s job.

Native first

Where the browser already knows the behavior, Calaca uses it. A dialog is a <dialog> with commandfor invokers, a dropdown is a [popover] with popovertarget, a select menu is ::picker(select). Small scripts fill the gaps browsers have not shipped yet, and each one names the day you can delete it: ca-commands.js exists only until invoker commands are universal.

Enforced, not agreed

None of this depends on the contributor remembering a style guide:

  • naming.test.ts rejects a class outside ca-block__part--modifier.
  • split.test.ts fails when two layers set the same property.
  • logical.test.ts rejects physical directions (ml-*, left-*) so every layout works right-to-left.
  • The skin build fails on a class no contract defines, so a typo never ships.
  • logical properties, pigments and minimum target sizes are contract, not preference.

The model survives contributors who never read this page. That is the point.