Skip to content

CSS-Only Custom Elements

The built-in CSS detector finds UNdefined custom elements that are intended to work without JavaScript. It scans project .css files and emits a CEM declaration when a custom-element selector is preceded by a JSDoc-style comment. It reads element names from plain selectors, selector lists, :is(), :where(), and @scope roots, and it discovers slots from slot="..." selectors and @slot tags.

Documenting An Element

Put a JSDoc style comment (/** ... */) immediately before the selector:

/**
* A badge styled entirely with CSS.
*/
my-badge {
/** Inner spacing. */
--badge-padding: 4px;
/** Surface color. */
--badge-bg-color: lightgray;
/** Text color. */
--badge-fg-color: black;
}

The generated declaration uses my-badge as both its name and tagName, and the selector comment becomes its description. Because CSS-only elements have no JavaScript implementation, the declaration references HTMLUnknownElement as its superclass, which is the browser’s representation of an undefined custom element.

The element comment can also use @cssprop or @cssproperty tags. These tags are merged into the generated cssProperties collection and override matching values discovered from the stylesheet:

/**
* A documented badge.
* @cssprop [--badge-padding=8px] - Override the default spacing.
* @cssprop --badge-outline-color - Optional outline color.
*/
my-badge {
/** Base spacing. */
--badge-padding: 4px;
}

Selectors without a preceding JSDoc-style comment are ignored. A regular CSS comment is not enough:

/* Not detected. */
my-undocumented-element {
--color: red;
}

Selector Support

The comment is attached to the rule that follows it, so the detector reads the element names from that rule’s selector. Plain selectors, selector lists, :is(), :where(), and @scope roots are all recognized:

/** Shared reset. */
:where(my-badge, my-chip, my-tag) {
box-sizing: border-box;
}
/** Card styles. */
@scope (my-card) to ([slot]) {
h3 {
font-weight: 600;
}
}

This emits my-badge, my-chip, and my-tag from the :where() list (each sharing the reset description) and my-card from the @scope root. :is() is treated the same way, including when it lists a class fallback such as :is(my-badge, .my-badge).

Names inside :not()/:has() are not treated as elements, and a class-only selector such as .my-badge is never emitted. When the same element is documented by more than one rule, the declarations are merged and the first description wins.

CSS Custom Properties

Custom-property declarations inside a CSS-only element must also have their own preceding JSDoc-style comment. This prevents implementation-only variables from being added to the public CEM API. Use @cssprop in the element comment for documented properties that are not declared directly in the rule.

@property rules are always included when they declare a custom property. Their syntax and initial-value are emitted as syntax and default; a preceding JSDoc comment is emitted as the property’s description when present.

/** Corner radius. */
@property --badge-radius {
syntax: "<length>";
initial-value: 4px;
inherits: false;
}

Nested variant rules can continue to override the element’s styles without changing the base metadata:

my-badge {
/** Base surface color. */
--badge-bg-color: lightgray;
&[variant="danger"] {
--badge-bg-color: red;
}
}

Slots

CSS-only elements have no shadow root, so slot="..." is a naming convention rather than browser-level projection. The detector still records those names so composition is visible in the manifest.

Slots are discovered from slot="..." attribute selectors anywhere in the element’s rule, including nested and descendant selectors:

/** A badge. */
my-badge {
[slot="icon-start"] {
order: -1;
}
[slot="icon-end"] {
order: 1;
}
}

This emits icon-start and icon-end. Use @slot in the element comment to document them, or to declare slots that are not styled directly:

/**
* A card.
* @slot media - The media area.
* @slot header - The card heading.
* @slot body - The card body.
* @slot footer - The card footer.
*/
@scope (my-card) to ([slot]) {
h3 {
font-weight: 600;
}
}

@slot and discovered names are merged by name; the @slot description wins. Presence-only [slot] selectors do not create a named slot.

Conservative Attribute Detection

The detector recognizes simple attribute selectors attached directly to the custom-element selector, including nested &[...] selectors:

/** A badge with variants. */
my-badge {
&[variant="danger"] {
--badge-bg-color: red;
}
}

This emits an attribute named variant. Add @attr or @attribute to the element comment to provide its description or other metadata:

/**
* A badge with variants.
* @attr variant - Selects the badge style.
*/
my-badge[variant] {
/** Base surface color. */
--badge-bg-color: lightgray;
}

Exact values in simple selectors are combined into a string-literal union in the attribute type. For example, variant="danger" and variant="success" produce "danger" | "success". Presence-only selectors such as [disabled] do not produce an inferred type. An explicit type in an @attr {Type} tag takes precedence.

Presence-only selectors are not automatically treated as booleans because CSS presence matching does not define how the component interprets the value. If an attribute is a boolean API, declare that explicitly:

/**
* A dismissible alert.
* @attr {boolean} dismissible - Whether the alert can be dismissed.
*/
alert-box[dismissible] {
display: block;
}

This emits dismissible with type.text set to boolean.

Attribute detection handles selectors attached directly to the custom-element selector, selectors attached to an :is()/:where() wrapper, and nested selectors beginning with &:

/** A compact badge. */
:where(my-badge)[compact] {
padding: 2px;
}

Attributes on descendant selectors are intentionally not inferred, since those selectors target child elements rather than the custom element itself.

File Filtering

CSS files are scanned automatically when include is omitted or empty. If include is provided, a CSS file must match one of its patterns. exclude patterns always take precedence.

const manifest = generateCem({
include: ["src/components/**"],
exclude: ["**/*.test.css"],
});