Skip to content

spiracss/property-placement

Validates property placement (container / item / internal).

Purpose

  • Separate layout responsibilities between parent and child to clarify structural meaning
  • Prevent layout decisions from leaking into the child side

What it checks

  • Disallow layout properties on page-root selectors (standalone body or standalone #id)
Categoryroot .block> .child-blockNotes
Containerchild Element (> .element) is treated as root — ✅
Itemany direct child (> .child Block / Element); + / ~ allowed only as trailing sibling selectors
Internalchild Element is treated as root — ✅. Includes padding / overflow / width·height·inline-size·block-size·min·max (when sizeInternal is enabled)
  • Enforce one-sided vertical margin (marginSide)
  • Restrict position (position: true)
  • @extend is always an error; @at-root is only allowed in the interaction section

✅ Correct

.parent-block {
display: flex; // container
gap: 16px;
> .child-block {
margin-top: 16px; // item
}
> .title {
padding: 8px; // internal
}
}

❌ Incorrect

.parent-block {
> .child-block {
padding: 16px; // ❌: internal properties on a child Block
}
}
body {
display: flex; // ❌: layout properties are not allowed on page-root
}
.parent-block {
> .child-block {
margin-bottom: 16px; // ❌: when marginSide is top
}
}

Why

  • Preserve the responsibility split: the parent defines container layout, the parent’s direct child rules adjust the child, and the child only styles its internals
  • Unifying vertical margin to one side simplifies spacing calculations and prevents duplication
  • @extend / improper @at-root breaks placement tracing and collapses structure

About sizeInternal (why size is treated as internal)

Size properties (width / height / inline-size / block-size / min-* / max-*) are used both as component defaults and as layout constraints. Neither the property name nor the value reliably indicates which, making intent ambiguous:

ExampleLikely intentBut…
width: 1eminternal (icon size)context still matters (font-size)
width: 100%item properties (fill parent)often used as a component default
max-width: 600pxinternal (component cap)can still be overridden by layout needs
max-width: 50%item properties (relative to parent)depends on layout meaning
width: fit-contentinternal (fit to content)may want to resize per context
width: min(320px, 100%)internal (capped fluid)100% is parent-dependent, making intent ambiguous

SpiraCSS resolves this by unifying size as internal (sizeInternal: true by default)—keeping the rule simple so Stylelint can enforce placement deterministically.

If your project prefers to write size directly on > .child-block without CSS variables, set sizeInternal: false. Size properties will then become unchecked by this rule (they are not reclassified as item properties; placement validation is simply skipped for them), so you will rely more on human review.

About position (child Block restrictions)

When position: true (the default), extra checks are added for child Block selectors:

ValueAllowed on > .child-block?Condition
static
relative⚠️ conditionalat least one offset (top / right / bottom / left / inset*) required; otherwise use in the child Block’s own file
absolute⚠️ conditionalat least one offset required; otherwise use in the child Block’s own file
fixeduse in the child Block’s own file
stickyuse in the child Block’s own file
dynamic position value (var(...) etc.)offset checks cannot be verified
initial / inherit / unset / revert / revert-layer— skippedvalidation is not applied (CSS-wide keywords + initial are treated as intentional resets)

Offsets must appear in the same “wrapper context”:

  • @media / @supports / @container / @layer are treated as transparent
  • @scope creates a new context boundary
  • @include mixins listed in responsiveMixins are treated as transparent

Exceptions / notes

  • If min-* values are 0, they are allowed even on child Blocks (when sizeInternal is enabled)
  • 0 / auto / initial are allowed on the forbidden side
  • If the entire value is inherit / unset / revert / revert-layer alone, it is skipped
  • If the forbidden side is var(...) / a function value / $... / #{...}, it is an error
  • @scope is treated as a context boundary; @include in responsiveMixins is treated as transparent
  • CSS Modules :global / :local are treated as transparent (the underlying selector is linted)
  • u- is always treated as external (classes listed in external are also excluded)
  • Selector coverage: This rule only verifies selectors that can be resolved as direct child chains (>). Descendant combinators (space), mid-chain + / ~, and other unsupported patterns are silently treated as unverified for placement checks. When marginSideTags is true (default), tag-selector rules in the selector chain are still checked for marginSideViolation (tags used only inside pseudo arguments like :is(main) are not counted as tag-selector rules). If the number of resolved selector combinations exceeds the internal limit (currently 1,000), remaining combinations are skipped (a selectorResolutionSkipped warning is reported).

Error list

containerInChildBlock (container properties in a child Block)

Example:

// ❌
.parent-block {
> .child-block {
display: flex;
gap: 8px;
}
}
// ✅
.parent-block {
display: flex;
gap: 8px;
}

Reason: container layout belongs to the parent or the child itself

itemInRoot (item properties on a root Block)

Example:

// ❌
.child-block {
margin-top: 16px;
}
// ✅
.parent-block {
> .child-block {
margin-top: 16px;
}
}

Reason: a root Block does not decide its own placement; the parent does

selectorKindMismatch (mixed selector kinds)

Example:

// ❌
.parent-block {
> .title,
> .child-block {
margin-top: 16px;
}
}
// ✅
.parent-block {
> .title {
margin-top: 16px;
}
> .child-block {
margin-top: 16px;
}
}

Reason: mixing root / Element / child Block makes placement detection impossible

marginSideViolation (forbidden side of vertical margin)

Example:

// ❌
.parent-block {
> .title {
margin-bottom: 16px;
}
}
// ✅
.parent-block {
> .title {
margin-top: 16px;
}
}

Reason: unify to one side to simplify spacing

internalInChildBlock (internal properties on a child Block)

Example:

// ❌
.parent-block {
> .child-block {
padding: 16px;
}
}
// ✅
.child-block {
padding: 16px;
}

Reason: internal properties belong to the child itself

positionInChildBlock (position restrictions on a child Block)

Example:

// ❌
.parent-block {
> .child-block {
position: fixed;
top: 0;
}
}
// ❌
.parent-block {
> .child-block {
position: relative;
}
}
// ❌
.parent-block {
> .child-block {
position: var(--pos);
top: 0;
}
}
// ✅
.parent-block {
> .child-block {
position: relative;
top: 0;
}
}

Reason: keep layout context between parent/child and enable offset checks

pageRootContainer (container properties on page-root)

Example:

// ❌
body {
display: flex;
gap: 12px;
}
// ✅
.page-root {
display: flex;
gap: 12px;
}

Reason: page-root is decoration-only; layout should be delegated to Blocks

pageRootItem (item properties on page-root)

Example:

// ❌
body {
margin-top: 16px;
}
// ✅
.page-root {
> .child-block {
margin-top: 16px;
}
}

Reason: page-root does not carry its own placement

pageRootInternal (internal properties on page-root)

Example:

// ❌
body {
padding: 16px;
}
// ✅
.page-root {
padding: 16px;
}

Reason: internal properties belong to Blocks

pageRootNoChildren (compound selector for page-root)

Example:

// ❌
body > .main {
color: #222;
}
// ✅
body {
color: #222;
}

Reason: page-root is treated as a standalone selector

forbiddenAtRoot (@at-root outside interaction)

Example:

// ❌
.parent-block {
@at-root & {
color: red;
}
}
// ✅
.parent-block {
// --interaction
@at-root & {
color: red;
}
}

Reason: @at-root breaks structure, so it is interaction-only

Exception: external-only roots. If a root rule is composed only of external classes (from external.classes / external.prefixes) and the interaction comment is present, @at-root is allowed. Any non-class selector (tag / id / attribute / pseudo) or any non-external class makes it invalid.

Example (external-only root):

// ✅ (external-only root + interaction)
.swiper-pagination {
// --interaction
@at-root & {
&:focus-visible {
outline: 3px solid #000;
}
}
}

forbiddenExtend (@extend is forbidden)

Example:

// ❌
.parent-block {
@extend %placeholder;
}
// ✅
.parent-block {
@include placeholder();
}

Reason: @extend introduces implicit dependencies and breaks placement tracing

selectorResolutionSkipped (selector resolution skipped)

Content:

.parent-block {
&.-a, &.-b, &.-c, &.-d, &.-e, &.-f, &.-g, &.-h {
> .child-block {
padding: 1px;
}
}
}

Reason: selectors too complex to resolve are skipped

selectorParseFailed (selector parse failed)

Example:

// ❌
.parent-block {
> : {
padding: 4px;
}
}
// ✅
.parent-block {
> .title {
padding: 4px;
}
}

Reason: unparseable selectors cannot be validated

Settings