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, afalse &&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.csshas 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/-endforms),column-count,columns,orphans,widows,tab-size,animation-iteration-count, and--customproperties. The grid placements are in that list because of a real trap: the kanban probe caughtgrid-column: 2pxbefore the list did. false/null/undefinedentries are skipped, so conditionals compose without ternary noise:background: active.value && 'red'.- camelCase maps to kebab-case (
marginLeft→margin-left);--custom-propertiespass 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 adata-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>failsLJS-105, because styles are parsed once. Per-instance values belong instyle="$"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.tscontains the component and its full stylesheet in a<style>tag, every selector prefixedlm-carousel-*.- There is no
style.cssin the package, no./style.cssexports entry in itspackage.json, and its demo page loads no<link>. - Its
package.jsondescription states the deployment fact: styles ship inside the component, in every form:import Carousel,<Carousel />by name, orcreateWebComponent(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>isLJS-105. Dynamic values go instyle="${() => 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.