|
|
3 months ago | |
|---|---|---|
| .. | ||
| README.md | 3 months ago | |
| index.ts | 3 months ago | |
| sticky.css | 3 months ago | |
| sticky.svelte | 3 months ago | |
| types.ts | 3 months ago | |
README.md
Sticky
A position: sticky wrapper that KNOWS when it is pinned, so a header /
toolbar can change elevation the moment it sticks. Built 2026-07-21 as F1.1
of the blocks initiative (docs/process/PLAN-blocks.md) — the first
BEHAVIORAL F1 piece, and the one that unblocks the site-header block (F2.1).
Reference floor + reflow doctrine: the research dossier
(docs/process/RESEARCH-blocks-references.md §P3 sticky) under the E-2 rule
(parity floor = v1). Headless behavior:
soma/components/sticky.
Baseline
- Classification: passive (0 events, 0 keyboard) but membership met by
COMPLEX BEHAVIOR — IntersectionObserver stuck-detection + provider/sentinel
composition.
apg: none. - Anatomy:
Sticky(box →data-sticky,data-edge,data-stuck) + an internal in-flowdata-sticky-sentinel(aria-hidden). The eidos layer is thin: it adds onlyposition: sticky, the inset (from the soma-injected--_sticky-offset), a--sticky-z-indextoken, and the layout-neutral sentinel. It POSITIONS; it does not decorate. - The stuck contract:
data-stuck(present while pinned) +data-edge(top | bottom) mirror the platform CSS@container scroll-state(stuck: top|bottom)(Chrome/Edge 133+; not Firefox/Safari ≤2026). So[data-sticky][data-stuck][data-edge='top']today translates mechanically to the nativescroll-state()query when it ships everywhere — this component is a polyfill of a future CSS contract. - Detection is IntersectionObserver, never scroll +
getBoundingClientRect(the reflow-doctrine violation). The IO motor is the one Chrome itself blesses;uix.perf's reflow detector can prove the demo does zero forced-reflow — a measurable quality claim no reference library makes.
Comparativa
| Ref | Equivalente | Qué adoptamos | Qué no |
|---|---|---|---|
AntD Affix |
offsetTop/offsetBottom/target/onChange(affixed) |
offset per edge, an explicit scroll-host (root ≈ AntD target) |
su implementación scroll-listener + position:fixed swap (su propia FAQ recomienda sticky nativo); onChange → evento reservado, no v1 |
Mantine Affix |
— | (no comparable) | es un slot position:fixed en Portal (FAB), sin noción de «pegado» |
| Chrome sentinel technique | dual-sentinel IntersectionObserver + sticky-change event |
el motor: centinela IO bendecido por Chrome (cumple la doctrina anti-reflow) | un evento síncrono (el nuestro reserva el nombre; v1 solo estampa CSS) |
| Radix / Base UI / Ark / React Aria | — (ninguno lo shippea) | seríamos los primeros headless-con-contrato en affix/sticky | — |
CSS scroll-state(stuck:) |
@container scroll-state(stuck: top/bottom) |
espejamos su vocabulario en data-stuck/data-edge |
esperar a que aterrice (Chromium-only ≤2026); somos el polyfill cross-browser mientras tanto |
Decisiones
- Un solo eje por instancia (
edge: 'top' | 'bottom', defaulttop): cubre header (top) y footer (bottom). Detección simultánea en ambos bordes = Gap diferido. offsetes un NÚMERO (px), no una string CSS: el provider necesita resolverlo para elrootMargindel observer (acoplado al inset). Offsets en unidades no-px = diferido.- El centinela es un hermano de flujo del box, no un hijo (un hijo se
fija con el box y nunca detecta). Renderizado como dos nodos raíz hermanos;
visibility:hidden,pointer-events:none, altura real 2px con margen negativo → layout-neutral (una superposición cero-área nunca dispara IO). - El tratamiento del estado pegado es del consumidor/block, no del
recipe: el recipe posiciona (
position:sticky+ inset + z-index); el consumidor selecciona[data-sticky][data-stuck]para sombra/fondo. Esto mantiene el componente neutral y componible. - Scroll-host explícito (
root), default viewport: sin helper de scroll-parent en la superficie bendecida de$adom(el único vive en$ethereal, internals de floating-UI que no se importan ad-hoc), la resolución del ancestro con overflow queda del lado del consumidor en v1. - Evento
change-stuckreservado en README, NO en el morfo: declararlo volvería el componenteinteractivey arrastraría todo el aparato sema/event-trace para un evento que no dispara en v1. Reservar el nombre aquí mantiene el contrato estable sin el coste.
Passive justification
Sticky declares 0 events because pinning is a fact of layout/scroll, not a
perceptual occurrence the user commits — the box slides into its pinned
position as a consequence of scrolling, and data-stuck is a styling signal
(one frame late, IO being async), never a commit. Membership is met by the
complex stuck-detection behavior, not a widget pattern (apg: none). No
keyboard, no state machine beyond the observed stuck flag. If a later
version needs to notify the app on pin/unpin, the change-stuck event name
is reserved (Gaps) and its addition will not reshape the contract.
Sema events
None (see Passive justification). SemaPanel in the demo renders the
justified empty state.
Gaps
| Gap | Disposition |
|---|---|
change-stuck / onStuckChange(edge) event (AntD onChange parity) |
diferir — nombre reservado aquí; su adición pasa por soma+sema (regla de admisión), no por un callback de wrapper |
Auto-resolución del scroll-host + dev-warn de ancestro overflow:hidden |
diferir — necesita un helper getScrollParent/getOverflowAncestors en $adom (hoy solo existe en $ethereal, internals no importables). Candidato a añadir a $adom; hasta entonces el consumidor pasa root |
| Ambos bordes simultáneos (stuck-top Y stuck-bottom en una instancia) | diferir — v1 = un eje por instancia; raro en la práctica |
offset en unidades no-px (rem/%) |
diferir — el provider resuelve px para el rootMargin; una resolución px en runtime (getComputedStyle vía dom.measure) se añade si un consumidor real lo pide |
Migración a @container scroll-state(stuck:) nativo cuando el soporte llegue |
diferir — el contrato de attrs ya lo espeja; el recipe añadirá el bloque @container como progressive enhancement, reteniendo el polyfill IO como fallback |