Styling

Two tools, two jobs: css() builds a style value from an object, and a <style> tag inside html ships a component’s CSS in its own source, lifted at parse time, injected once per template (as a constructed stylesheet the document adopts):

import { css } from 'lemonadejs';

const Chip = (props, { state }) => {
    const y = state(12);
    return html`<span class="lm-chip" style="${() => css({ top: y.value })}">
        <style>.lm-chip { padding: 2px 8px; border-radius: 12px; }</style>
        ${props.label}
    </span>`;
};
// → "top:12px" on the element; the rule adopted by the document, once

Background

Two challenges recur in component styling: handling dynamic values reliably, and keeping styles colocated with the component that owns them.

  • String-built style attributes. Hand-concatenating 'top:' + y + 'px;left:' + x + 'px' is the zero-tooling approach, and it carries predictable slips: a forgotten unit, a forgotten semicolon, a false && conditional stringified into the attribute. None of these produce an error; the browser drops what it cannot parse.
  • The distribution question. A component that ships as index.js + style.css has two artifacts to keep in sync, an extra import (or <link>) for every consumer, and a failure mode, styles forgotten, that renders as a working but unstyled component.

How LemonadeJS solves it

css() is a value helper, not a styling engine. It takes an object with typed keys and returns the style string:

css({ top: y.value, left: x.value, opacity: 0.5, background: active.value && 'red' })
// → "top:12px;left:40px;opacity:0.5"        (active is false here)
  • Numbers get px, except the properties that are unitless by spec: opacity, z-index, zoom, order, flex (-grow, -shrink), font-weight, line-height, scale, aspect-ratio, grid-row/column/area (and their -start/-end forms), column-count, columns, orphans, widows, tab-size, animation-iteration-count, and --custom properties. The grid placements are in that list because of a real trap: the kanban probe caught grid-column: 2px before the list did.
  • false/null/undefined entries are skipped, so conditionals compose without ternary noise: background: active.value && 'red'.
  • camelCase maps to kebab-case (marginLeftmargin-left); --custom-properties pass through as written.

<style> in a template is component-owned CSS. The tag never enters the DOM tree: the parser lifts it out and the engine injects its text as a constructed stylesheet adopted by the document (adoptedStyleSheets) once per template: mount a thousand instances, get one stylesheet. The mechanics:

  • It is a CSSOM stylesheet, not a <style> element, so it works under a strict Content Security Policy with no nonce. (Browsers without constructable stylesheets (pre-2023) fall back to a data-lemonade <style> element.) Either way it dedupes by call-site identity (the template literal itself).
  • It persists for the document lifetime: unmounting every instance does not remove it, remounting never duplicates it. Styles are treated like code: loaded once, kept.
  • The CSS must be static: $ inside <style> fails LJS-105, because styles are parsed once. Per-instance values belong in style="$" or CSS custom properties.
  • Styles are global by design; there is no scoping rewrite. The convention is the same one the Studio uses everywhere: prefix every selector with your component class (lm-chip, lm-carousel-*). Branch templates inject when the branch first renders.

The two halves meet in the single-file component: one .ts file that carries its logic, its markup and its working styling, in every deployment (by value, by name, or as a custom element) with no build step and no companion artifact.

In practice: the receipt

The carousel is a single-file block. It is the first Studio block built after style hoisting landed, and its file layout is the claim:

  • src/index.ts contains the component and its full stylesheet in a <style> tag, every selector prefixed lm-carousel-*.
  • There is no style.css in the package, no ./style.css exports entry in its package.json, and its demo page loads no <link>.
  • Its package.json description states the deployment fact: styles ship inside the component, in every form: import Carousel, <Carousel /> by name, or createWebComponent(Carousel) in a page that has never heard of a bundler.
// components/carousel/src/index.ts (abridged)
return html`<div class="lm-carousel" ...>
    <style>
        .lm-carousel { position: relative; overflow: hidden; ... }
        .lm-carousel-strip { display: flex; ... }
        .lm-carousel-dragging .lm-carousel-strip { transition: none; }
    </style>
    ...
</div>`;

css() carries the dynamic half. The same carousel positions its strip with a live computed transform; the modal builds drag positions with css({ top: y.value, left: x.value }); the kanban places cards with unitless gridColumn values, and the helper’s unitless table is what keeps that last one honest.

The engine suite pins all of it: hoisted styles stay out of the component DOM, inject once across many instances, inject from branches on first render, reject $ with LJS-105. And css()’s unit, camelCase, custom-property and conditional behavior is pinned case by case (tests/style.test.ts).

Reference

import { css } from 'lemonadejs';

css(values)        // → "prop:val;prop:val", for style="$"
// numbers → px, except unitless props (opacity, z-index, zoom, order,
//   flex(-grow/-shrink), font-weight, line-height, scale, aspect-ratio,
//   grid-row/column/area(+-start/-end), column-count, columns, orphans,
//   widows, tab-size, animation-iteration-count, --custom)
// false/null/undefined entries skipped (conditionals compose)
// camelCase → kebab-case; '--custom': 'x' passes through

// Component-owned CSS
html`<div class="lm-name">
    <style>.lm-name { ... }</style>     <!-- lifted at parse, never in the tree -->
</div>`
  • One injection per template, data-lemonade-tagged, persists for the document lifetime; remounts never duplicate.
  • Static only: $ inside <style> is LJS-105. Dynamic values go in style="${() => css()}" or CSS custom properties.
  • Global by design: prefix selectors with the component class, the lm-<name>-* convention.

For attribute binding mechanics, read Attributes; for the block-building conventions around lm-* classes, read Building blocks.