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
bodyor standalone#id)
| Category | root .block | > .child-block | Notes |
|---|---|---|---|
| Container | ✅ | ❌ | child Element (> .element) is treated as root — ✅ |
| Item | ❌ | ✅ | any direct child (> .child Block / Element); + / ~ allowed only as trailing sibling selectors |
| Internal | ✅ | ❌ | child 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) @extendis always an error;@at-rootis 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-rootbreaks 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:
| Example | Likely intent | But… |
|---|---|---|
width: 1em | internal (icon size) | context still matters (font-size) |
width: 100% | item properties (fill parent) | often used as a component default |
max-width: 600px | internal (component cap) | can still be overridden by layout needs |
max-width: 50% | item properties (relative to parent) | depends on layout meaning |
width: fit-content | internal (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:
| Value | Allowed on > .child-block? | Condition |
|---|---|---|
static | ✅ | — |
relative | ⚠️ conditional | at least one offset (top / right / bottom / left / inset*) required; otherwise use in the child Block’s own file |
absolute | ⚠️ conditional | at least one offset required; otherwise use in the child Block’s own file |
fixed | ❌ | use in the child Block’s own file |
sticky | ❌ | use in the child Block’s own file |
dynamic position value (var(...) etc.) | ❌ | offset checks cannot be verified |
initial / inherit / unset / revert / revert-layer | — skipped | validation is not applied (CSS-wide keywords + initial are treated as intentional resets) |
Offsets must appear in the same “wrapper context”:
@media/@supports/@container/@layerare treated as transparent@scopecreates a new context boundary@includemixins listed inresponsiveMixinsare treated as transparent
Exceptions / notes
- If
min-*values are0, they are allowed even on child Blocks (whensizeInternalis enabled) 0/auto/initialare allowed on the forbidden side- If the entire value is
inherit/unset/revert/revert-layeralone, it is skipped - If the forbidden side is
var(...)/ a function value /$.../#{...}, it is an error @scopeis treated as a context boundary;@includeinresponsiveMixinsis treated as transparent- CSS Modules
:global/:localare treated as transparent (the underlying selector is linted) u-is always treated as external (classes listed inexternalare 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. WhenmarginSideTagsistrue(default), tag-selector rules in the selector chain are still checked formarginSideViolation(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 (aselectorResolutionSkippedwarning 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