352. An element enters from its starting style
Date: 2026-09-17
Status
Accepted. Extends ADR-0067.
Builds design-system.md §3.1’s button[float] entrance, which
ADR-0347 left
unbuilt. The way out it left unbuilt is built by
ADR-0355.
Context
ADR-0067 decided that an element’s first frame starts no transition. There is no previous style to move from, and a window that faded every control in from black as it opened would be wrong. That rule is still right for nearly everything.
It also left no way to say “this particular element arrives”. §3.1 specifies
button[float] as “in: opacity + scale 0.9→1, base”, and ADR-0347 could not
build it because the overlay layer mounts a widget already at rest. The same gap
kept check-mark and radio-dot mounted and invisible in every unchecked
control. A mark that came into existence checked would have snapped instead of
growing in.
CSS has an answer to this exact question, and it is not a new lifecycle API:
@starting-style. Rules inside it describe the style an element has before its
first style, so the transitions the element already declares run from there.
Decision
@starting-style { rules } is in the subset. On an element’s first styled
frame, if a starting rule matches it, the renderer observes the starting style
first and the element’s real style second, so its declared transitions run from
the one to the other.
- Parser. The block form at the top level of a sheet, with no prelude. Its
rules are ordinary
StyleRules withstarting = true, numbered in source order among the other rules. A nested at-rule or a prelude is refused. - Cascade.
StyleResolverputs starting rules in buckets of their own. They are never part ofresolve, so a starting rule can never leak into the style an element has.resolveStarting(element)returns null unless a starting rule matches. Otherwise it runs the ordinary cascade with the matching starting rules added in their source positions. That is CSS’s “before-change style”: a starting rule that says onlyopacity: 0leaves every other property where the element’s rules put it.var()is substituted against the element’s own custom properties, and not against any a starting rule declares, which would otherwise be cached as the element’s for every later frame. - Renderer.
Element.firstStyled()answers true exactly once per element. On that frame, if the element’s style declares transitions and a starting style resolves,Animations.observe(starting)runs beforeobserve(style). The widget’srestyleis applied to the starting style too, so an inline value such as a segmented indicator’s position does not appear to move. - No transition, no entrance, as in CSS. A starting style only chooses where transitions start from.
- Reduced motion reduces the starting style’s transitions as it reduces every other style’s, so the element arrives at once.
- Once. A rebuild keeps the element, so the element does not enter again. A keyed element that moves keeps its element. An element that is unmounted and mounted again is a new element, and it enters again.
Consequences
button.floatenters fromopacity: 0; transform: scale(0.9)over--gb-motion-base. Its press still snaps because of abutton.float:activerule, since the float rule would otherwise out-rankbutton:activeby source order.FloatEntranceTestpins the frames.- A sheet with no starting rules pays one boolean per element per frame.
hasStartingStyles()keeps the second cascade off the first frame entirely. - The way out is still not built. An overlay that is removed has no frame left
to transition in, and that is §1.7’s
closingphase rather than a stylesheet’s. check-markandradio-dotcould now be built only while checked and enter from a starting style. They are left as they are, because nothing about them is wrong and a golden would move for no visible gain.