Skip to main content

Trellis CSS Design Document

1. Purpose​

Trellis applications need a way to style components that is:

  • Type-safe — catch typos and invalid values at type-check time, not in the browser
  • Pythonic — feels natural alongside Trellis components, not a string-based escape hatch
  • Performant — minimal runtime overhead, especially for static styles
  • Composable — build complex styles from simple pieces, support variants and states
  • Scoped by default — avoid class name collisions across modules without manual namespacing

String-based CSS (inline or external) provides none of these. Existing Python CSS libraries are either too verbose (dataclasses with every property) or lack type safety (dict-based). Trellis CSS provides a builder API that is concise, fully typed, and optimized for the framework's reactive model.


2. Table of Contents​

  1. Purpose
  2. Table of Contents
  3. Features
  4. Examples
  5. Detailed Design

3. Features​

Core API​

  • Builder pattern, not dataclasses — More ergonomic, chainable, and enables caching via ops tuples. Dataclasses are too static for conditional composition.

  • Pipe operator (|) for composition — Combines styles, pseudo-states, and breakpoints. Reads left-to-right, later wins for precedence. | None is a no-op for conditional composition.

  • Pseudo-states and breakpoints return builders — hover().bg(blue) instead of .hover(lambda s: s.bg(blue)). Same pattern for focus(), active(), disabled(), sm(), md(), lg(), xl().

  • Unified css= prop — Framework decides whether to emit a class or inline style. Anonymous styles with pseudo-states auto-promote to generated classes.

  • Classes before inline styles — css() | style() is valid (inline overrides class). style() | css() is an error. This ensures clear precedence without generating CSS.

Type Safety​

  • Units as types — px(), rem(), pct(), em(), vh(), vw() return frozen objects that stringify to valid CSS. Type checker catches invalid combinations.

  • CSS functions as composable objects — rgb(), rgba(), hsl(), var(), calc(), linear_gradient(). Chainable transforms: translate(x).scale(y).rotate(z).

Performance​

  • Module-scope classes register at import — Pre-compiled during startup, zero render-time cost. Framework warns if css() is declared inside a render function.

  • Ops tuple for cheap hashing — Builder appends to an immutable tuple. Hash without building enables fast cache lookups.

  • LRU cache with bounded size — Same style object → cache hit. Cache can persist to disk for warm starts.

  • Dev mode hot style warnings — Tracks creation sites, warns when one line produces many unique style hashes.

Scoping​

  • File-scoped class names by default — css("btn") in button.py generates .button_btn_a7f3. Prevents collisions across modules.

  • Explicit global opt-in — css("reset", Scope.Global) for intentionally shared classes.

Inheritance​

  • extend() for build-time inheritance — Copies all styles from a base class into a new class with overrides. Generates a single flattened class.

  • | for runtime composition — Combines class names at render time. No flattening, relies on CSS cascade.


4. Examples​

4.1 Inline text styling​

from trellis.css import style, rgb

@component
def Price(amount: float, sale: bool = False):
# style() creates an anonymous inline style, conditionally applied
html.span(
css=style().color(rgb(220, 38, 38)).font_weight(700) if sale else None,
children=[f"${amount:.2f}"],
)

4.2 Declared class in a component​

from trellis.css import css, hover, px, rgb

# css() declares a named class, registered at module load
# hover() returns a builder for :hover pseudo-state
tag = (
css("tag")
.padding(px(4), px(10))
.bg(rgb(243, 244, 246))
.rounded(px(9999))
| hover().bg(rgb(229, 231, 235))
)


@component
def Tag(label: str):
html.span(css=tag, children=[label])

4.3 Extending a base class​

from trellis.css import css, hover, px, rem, rgb

# Base row style with hover state
row = (
css("list-row")
.display("flex")
.align_items("center")
.gap(rem(1))
.padding(rem(0.75), rem(1))
.cursor("pointer")
.border_bottom(px(1), rgb(243, 244, 246))
| hover().bg(rgb(249, 250, 251))
)

# extend() copies base styles into a new class with overrides
row_selected = row.extend("list-row-selected").bg(rgb(239, 246, 255)) | hover().bg(rgb(219, 234, 254))


@component
def ListRow(children, selected: bool = False, on_click: Callable[[], None] | None = None):
# Pick class based on state - just a reference, no runtime cost
html.div(css=row_selected if selected else row, on_click=on_click, children=children)

5. Detailed Design​

5.1 Builder Pattern​

The core API uses a builder pattern where each method returns a new builder instance. Internally, builders accumulate operations as an immutable tuple, enabling cheap hashing and caching.

class StyleBuilder:
__slots__ = ("_ops",)

def __init__(self, ops: tuple = ()):
self._ops = ops

def bg(self, color: Color) -> "StyleBuilder":
return StyleBuilder((*self._ops, ("bg", color)))

def padding(self, *args: Length) -> "StyleBuilder":
return StyleBuilder((*self._ops, ("padding", args)))

def __hash__(self) -> int:
return hash(self._ops)

Entry points:

  • style() — returns an anonymous StyleBuilder for inline styles
  • css(name) — returns a ClassBuilder that registers a named class

Usage:

# Anonymous inline
style().bg(rgb(255, 0, 0)).padding(px(8))

# Named class
css("btn").bg(rgb(0, 123, 255)).padding(px(8), px(16))

Methods should cover all common CSS properties with Pythonic names (snake_case). Properties that take complex values accept the appropriate unit/function types.


5.2 Pipe Operator for Composition​

The | operator combines styles. For named classes, it concatenates class names. For inline styles, it merges properties (later wins).

def __or__(self, other: "StyleBuilder | None") -> "StyleBuilder":
if other is None:
return self
return StyleBuilder((*self._ops, ("merge", other._ops)))

Precedence: Left-to-right, later values override earlier for the same property.

Conditional composition: | None is a no-op, enabling:

styles = base | (variant if condition else None) | (override if other_condition else None)

Ordering restriction: Classes must come before inline styles. css() | style() is valid (inline overrides class via natural CSS specificity). style() | css() is an error (class cannot override inline without generating CSS).

# Valid - class first, inline override
css("btn") | style().bg(red)
# → class="btn" style="background-color: red"

# Valid - multiple classes, then inline
btn | btn_primary | style().opacity(0.5)
# → class="btn btn-primary" style="opacity: 0.5"

# Error - class after inline
style().bg(red) | css("btn")
# → RuntimeError: class cannot follow inline style

# Error - class between inline styles
style().bg(red) | css("btn") | style().padding(px(8))
# → RuntimeError: class cannot follow inline style

Type-level enforcement: Use separate base types (ClassStyle and InlineStyle) with overloaded __or__ operators. ClassStyle | InlineStyle returns a MixedStyle that only accepts further InlineStyle. InlineStyle | ClassStyle raises TypeError at type-check time (or runtime if not caught).


5.3 Pseudo-States​

Pseudo-state functions return a builder scoped to that state. They compose with |.

def hover() -> PseudoBuilder:
return PseudoBuilder("hover")

def focus() -> PseudoBuilder:
return PseudoBuilder("focus")

def active() -> PseudoBuilder:
return PseudoBuilder("active")

def disabled() -> PseudoBuilder:
return PseudoBuilder("disabled")

Usage:

btn = (
css("btn")
.bg(blue)
.color(white)
| hover().bg(dark_blue)
| focus().ring(px(2), blue_300)
| active().bg(darker_blue)
| disabled().opacity(0.5).cursor("not-allowed")
)

Multiple properties: Pseudo builders are chainable, so hover().bg(x).color(y) applies both on hover.

Anonymous styles with pseudo-states: When style() is combined with pseudo-states, the framework auto-generates a class name (content-hashed) since inline styles cannot express :hover.


5.4 Responsive Breakpoints​

Breakpoint functions work like pseudo-states, returning a builder scoped to a media query.

def sm() -> BreakpointBuilder:  # min-width: 640px
return BreakpointBuilder("sm")

def md() -> BreakpointBuilder: # min-width: 768px
return BreakpointBuilder("md")

def lg() -> BreakpointBuilder: # min-width: 1024px
return BreakpointBuilder("lg")

def xl() -> BreakpointBuilder: # min-width: 1280px
return BreakpointBuilder("xl")

Usage:

layout = (
css("layout")
.padding(px(16))
.columns(1)
| sm().columns(2)
| md().columns(3).padding(px(24))
| lg().columns(4).padding(px(32))
)

Combining with pseudo-states:

card = (
css("card")
.shadow("sm")
| hover().shadow("md")
| md(hover()).shadow("lg") # larger shadow on hover, only on md+
)

5.5 Units​

Units are frozen dataclasses that stringify to valid CSS. Type aliases group related units for property signatures.

@dataclass(frozen=True, slots=True)
class Px:
value: float
def __str__(self) -> str:
return f"{self.value}px"

@dataclass(frozen=True, slots=True)
class Rem:
value: float
def __str__(self) -> str:
return f"{self.value}rem"

@dataclass(frozen=True, slots=True)
class Pct:
value: float
def __str__(self) -> str:
return f"{self.value}%"

# Shorthand constructors
def px(v: float) -> Px: return Px(v)
def rem(v: float) -> Rem: return Rem(v)
def pct(v: float) -> Pct: return Pct(v)
def em(v: float) -> Em: return Em(v)
def vh(v: float) -> Vh: return Vh(v)
def vw(v: float) -> Vw: return Vw(v)

# Type aliases for property signatures
Length = Px | Rem | Em | Pct | Vh | Vw
LengthOrAuto = Length | Literal["auto"]

Angle units for transforms/gradients:

def deg(v: float) -> Deg: return Deg(v)
def rad(v: float) -> Rad: return Rad(v)

Angle = Deg | Rad

Time units for animations/transitions:

def ms(v: float) -> Ms: return Ms(v)
def s(v: float) -> S: return S(v)

Time = Ms | S

5.6 Colors​

Color functions return frozen objects. Support RGB, RGBA, HSL, and CSS variable references.

@dataclass(frozen=True, slots=True)
class Rgb:
r: int
g: int
b: int
def __str__(self) -> str:
return f"rgb({self.r}, {self.g}, {self.b})"

@dataclass(frozen=True, slots=True)
class Rgba:
r: int
g: int
b: int
a: float
def __str__(self) -> str:
return f"rgba({self.r}, {self.g}, {self.b}, {self.a})"

def rgb(r: int, g: int, b: int, a: float | None = None) -> Rgb | Rgba:
if a is not None:
return Rgba(r, g, b, a)
return Rgb(r, g, b)

def hsl(h: int, s: float, l: float) -> Hsl:
return Hsl(h, s, l)

Color = Rgb | Rgba | Hsl | Var | Literal["transparent", "currentColor", "inherit"] | str

Color scales (generated, Tailwind-style):

class colors:
blue_50 = rgb(239, 246, 255)
blue_100 = rgb(219, 234, 254)
blue_500 = rgb(59, 130, 246)
blue_600 = rgb(37, 99, 235)
# ... etc for all colors and shades

5.7 CSS Functions​

CSS functions like calc(), var(), and gradients are typed objects.

calc():

@dataclass(frozen=True, slots=True)
class Calc:
expr: str
def __str__(self) -> str:
return f"calc({self.expr})"

# With operator overloading on units
width = calc(pct(100) - px(240)) # calc(100% - 240px)

var():

@dataclass(frozen=True, slots=True)
class Var:
name: str
fallback: str | None = None
def __str__(self) -> str:
if self.fallback:
return f"var(--{self.name}, {self.fallback})"
return f"var(--{self.name})"

# Usage
style().color(var("text-primary")).bg(var("bg-surface", "#ffffff"))

Gradients:

def linear_gradient(direction: Angle, *stops: Color) -> LinearGradient:
return LinearGradient(direction, stops)

def radial_gradient(*stops: Color, shape: str = "ellipse") -> RadialGradient:
return RadialGradient(stops, shape)

# Usage
style().bg(linear_gradient(deg(135), colors.blue_500, colors.purple_600))

url():

def url(path: str) -> Url:
return Url(path)

style().bg(url("/images/pattern.svg"))

5.8 Transforms​

Transforms are chainable. Each transform function returns a Transform object with a .then() method (or use method chaining directly).

@dataclass(frozen=True, slots=True)
class Transform:
ops: tuple

def translate(self, x: Length, y: Length | None = None) -> "Transform":
return Transform((*self.ops, ("translate", x, y)))

def scale(self, x: float, y: float | None = None) -> "Transform":
return Transform((*self.ops, ("scale", x, y)))

def rotate(self, angle: Angle) -> "Transform":
return Transform((*self.ops, ("rotate", angle)))

def skew(self, x: Angle, y: Angle | None = None) -> "Transform":
return Transform((*self.ops, ("skew", x, y)))

# Entry point
def translate(x: Length, y: Length | None = None) -> Transform:
return Transform(()).translate(x, y)

def scale(x: float, y: float | None = None) -> Transform:
return Transform(()).scale(x, y)

def rotate(angle: Angle) -> Transform:
return Transform(()).rotate(angle)

Usage:

card = (
css("card")
.transform(translate(px(0), px(0)))
.transition("transform", ms(150))
| hover().transform(translate(px(0), px(-4)).scale(1.02))
)

5.9 Animations and Keyframes​

Keyframes are declared with a builder. Animations reference keyframes by name or inline.

class KeyframesBuilder:
def __init__(self, name: str):
self.name = name
self.frames: list[tuple[int, StyleBuilder]] = []

def frame(self, offset: int, **props) -> "KeyframesBuilder":
# offset is 0-100 (percent)
self.frames.append((offset, style_from_props(props)))
return self

def keyframes(name: str) -> KeyframesBuilder:
return KeyframesBuilder(name)

Usage:

fade_in = (
keyframes("fade-in")
.frame(0, opacity=0)
.frame(100, opacity=1)
)

slide_up = (
keyframes("slide-up")
.frame(0, opacity=0, transform=translate(px(0), px(20)))
.frame(100, opacity=1, transform=translate(px(0), px(0)))
)

animated_box = (
css("animated-box")
.animation(fade_in, duration=ms(300), timing="ease-out", fill="forwards")
)

Animation properties:

.animation(
keyframes, # KeyframesBuilder or name string
duration: Time,
timing: Literal["linear", "ease", "ease-in", "ease-out", "ease-in-out"] | CubicBezier = "ease",
delay: Time | None = None,
iteration_count: int | Literal["infinite"] = 1,
direction: Literal["normal", "reverse", "alternate", "alternate-reverse"] = "normal",
fill: Literal["none", "forwards", "backwards", "both"] = "none",
)

5.10 Named Classes vs Inline Styles​

css(name) declares a named class:

  • Registered with the framework at module load time
  • Compiled once, referenced by class name at render time
  • Supports pseudo-states and breakpoints natively
  • File-scoped by default

style() creates an anonymous inline style:

  • Evaluated at render time (but cached by ops hash)
  • Emits style="..." attribute when possible
  • Auto-promotes to generated class if pseudo-states or breakpoints are used

Choosing between them:

Use caseApproach
Reusable component stylescss() at module scope
One-off dynamic stylingstyle() inline
Styles based on propsstyle() or pre-declared variants with css()

5.11 Class Inheritance with extend()​

extend() creates a new named class based on an existing one. All properties are copied at build time, producing a single flattened class.

class ClassBuilder:
def extend(self, new_name: str) -> "ClassBuilder":
# Returns a new ClassBuilder with all ops from self,
# registered under new_name
return ClassBuilder(new_name, base_ops=self._ops)

Usage:

btn = css("btn").padding(px(8), px(16)).rounded(px(4))

btn_primary = btn.extend("btn-primary").bg(blue).color(white)
btn_danger = btn.extend("btn-danger").bg(red).color(white)

Contrast with |:

  • extend() — build-time, flattens into one class, parent changes don't propagate
  • | — runtime, concatenates class names, relies on CSS cascade

5.12 Scoping​

By default, class names are scoped to the Python file where they are declared. This prevents collisions without requiring manual namespacing.

# components/button.py
btn = css("btn").bg(blue)
# → generates: .button_btn_a7f3 { ... }

# components/card.py
btn = css("btn").bg(green) # different file, no collision
# → generates: .card_btn_b2e1 { ... }

Explicit global scope:

from trellis.css import css, Scope

reset = css("reset", Scope.Global).margin(px(0)).padding(px(0))
# → generates: .reset { ... }

Implementation: The framework inspects the call site (via inspect or a frame hack) to determine the source file. The scope suffix is a hash of the file path.


5.13 Tailwind-Style Utilities​

For rapid prototyping or utility-first workflows, pre-defined atomic classes are available under the tw namespace.

@dataclass(frozen=True, slots=True)
class Utility:
name: str
prop: str
value: str

def __str__(self) -> str:
return self.name

def __or__(self, other: "Utility | UtilitySet | None") -> "UtilitySet":
if other is None:
return UtilitySet((self,))
if isinstance(other, UtilitySet):
return UtilitySet((self, *other.utilities))
return UtilitySet((self, other))

class tw:
flex = Utility("flex", "display", "flex")
block = Utility("block", "display", "block")
hidden = Utility("hidden", "display", "none")

p_1 = Utility("p-1", "padding", "0.25rem")
p_2 = Utility("p-2", "padding", "0.5rem")
p_4 = Utility("p-4", "padding", "1rem")
# ... etc

bg_blue_500 = Utility("bg-blue-500", "background-color", "#3b82f6")
text_white = Utility("text-white", "color", "#ffffff")
# ... etc

Usage:

html.div(
css=tw.flex | tw.p_4 | tw.bg_blue_500 | hover(tw.bg_blue_600),
children=[...],
)

With pseudo-states and breakpoints:

btn_classes = (
tw.flex | tw.justify_center | tw.py_2 | tw.px_4
| hover(tw.bg_blue_600)
| focus(tw.ring_2)
| sm(tw.px_6)
)

Utilities are generated from scales (spacing, colors, typography) and can be extended by the application.


5.14 Caching and Performance​

Ops tuple hashing:

Every builder method appends to an immutable tuple. The tuple is hashable, so we can cache compiled styles by hash without running build().

_style_cache: dict[int, CompiledStyle] = {}

def resolve_style(builder: StyleBuilder) -> CompiledStyle:
h = hash(builder)
if h not in _style_cache:
_style_cache[h] = builder.build()
return _style_cache[h]

Bounded cache:

Use cachetools.LRUCache to prevent unbounded growth:

from cachetools import LRUCache

_style_cache = LRUCache(maxsize=10_000)

Persistent cache:

For faster startup, serialize the cache to disk:

def save_cache(path: Path):
with open(path, "wb") as f:
pickle.dump(dict(_style_cache), f)

def load_cache(path: Path):
if path.exists():
with open(path, "rb") as f:
_style_cache.update(pickle.load(f))

Dev mode warnings:

Track where styles are created. Warn if a single source location produces many unique hashes:

_creation_sites: dict[tuple[str, int], set[int]] = {}  # (file, line) -> hashes

def track_creation(builder: StyleBuilder, frame: FrameInfo):
key = (frame.filename, frame.lineno)
_creation_sites.setdefault(key, set()).add(hash(builder))

if len(_creation_sites[key]) > 50:
warnings.warn(
f"Hot style at {key[0]}:{key[1]} - {len(_creation_sites[key])} unique styles. "
"Consider hoisting to module scope.",
StylePerformanceWarning,
)

5.15 The css= Prop​

Trellis elements accept a css= prop that handles both classes and inline styles. The framework inspects the value and emits the appropriate attributes.

Rules:

  1. Named class (ClassBuilder) → emits class="..."
  2. Anonymous style (StyleBuilder) without pseudo-states → emits style="..."
  3. Anonymous style with pseudo-states → auto-generates a class, emits class="..."
  4. Combined with | → concatenates classes or merges inline properties
  5. None → no styling

Example outputs:

html.button(css=btn)
# → <button class="button_btn_a7f3">

html.button(css=style().bg(blue))
# → <button style="background-color: rgb(0, 0, 255)">

html.button(css=style().bg(blue) | hover().bg(dark_blue))
# → <button class="_ts_7f3a">
# Framework emits: ._ts_7f3a { background-color: blue; }
# ._ts_7f3a:hover { background-color: darkblue; }

html.button(css=btn | tw.px_8 | hover(tw.scale_105))
# → <button class="button_btn_a7f3 px-8 hover:scale-105">

html.button(css=btn | style().opacity(0.5))
# → <button class="button_btn_a7f3" style="opacity: 0.5">

Restriction: Classes must come before inline styles. The framework enforces this ordering to ensure predictable specificity without generating CSS. Use separate types (ClassStyle, InlineStyle, MixedStyle) with overloaded __or__ to catch violations at type-check time when possible.

# Error at type-check (or runtime)
style().bg(red) | css("btn") # TypeError: ClassStyle cannot follow InlineStyle