GrAffix
Keeps a block in view while the page scrolls — a section header, a table of contents, form actions.
Overview
When to take it
- the actions of a long form — “Save” must not drive off the screen at the twentieth field; the panel sticks to the bottom and lets go when the form has ended;
- a section heading in a long list — the reader always sees where they are instead of scrolling back up;
- a table of contents or filters on the side — the column travels with the
content and stops under the application header; the highlighting of the
section in that column is given by
GrScrollSpy; - a panel inside its own scroller — in a resizable area, in the body of a window, in a scrollable card: the edge is taken from the nearest scrollport, not from the window;
- the look changes with whether it has stuck — the subtitle goes away, the
button shrinks to an icon: the state arrives in the slot and in
data-stuck.
When to take something else
| Need | Take |
|---|---|
| An application header with zones, a menu button and a landmark | GrNavbar |
| A table header that stays in place while the body scrolls | GrTable / GrDataTable |
| Bottom navigation between sections on mobile | GrBottomNav |
| Highlight the table-of-contents item that has been read up to | GrScrollSpy |
| A panel above the page rather than in its flow | GrDrawer / GrModal |
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrAffix><div class="flex items-center justify-between gap-3 px-4 py-2"><span class="font-600">Invoice INV-0418 · €5,166.50</span><GrButton size="sm">Save</GrButton></div></GrAffix>Examples 3 each with a live demo and its code
A toolbar that knows it has stuck
The panel sticks to the edge of its own scroller, not of the window.
stuckcomes into the slot: once the heading above has scrolled away, the panel repeats the number and the total, so «Send» is never pressed blind. The surface and the shadow come with sticking — detached, the panel is part of the page.INV-0418 DraftNorthwind Traders · due 21 October 20267 lines4 × Ergonomic chair€1,156.002 × Standing desk€1,280.0012 × Monitor arm€948.004 × Desk lamp€216.002 × Whiteboard€290.001 × Delivery to Hamburg€120.001 × Assembly on site€240.00Total€4,250.00Section headings under the app header
Each heading sticks under the header that is already pinned, and only inside its own section — the next section pushes it out. The offset is set once for all of them as the
--gr-affix-offsettoken on the container; the observer reads the computed style, so3remandvar(…)work the same as a prop.Billing settingsInvoices
Numbering: INV-0001Payment terms: 14 daysDefault currency: EURFooter note on every PDFReminders
First reminder: 3 days after dueSecond reminder: 10 days after dueCopy to the account ownerTaxes
VAT number: DE 812 345 678Reverse charge for EU customersRates by country of deliverySave stays in reach while there is something to save
placement="bottom"keeps the actions on the lower edge while the form is being read, and lets them take their place at the end of it.disabledturns sticking off until a field changes — the markup stays, so the focus on the buttons survives;@sticky-changereports the state.Billing profileAll changes saved
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
placement | "top" | "bottom" | undefined | "top" | The edge the panel sticks to. |
offset | string | number | undefined | undefined | The offset from the edge in the stuck state. A number is pixels, a string is taken as is (`4rem`, `var(--gr-navbar-height)`, `calc(…)`). A shared offset for a group of panels is set not by the prop but by the `--gr-affix-offset` token on the container or on `:root`: the observer reads the computed style and picks it up the same way as the value of the prop. |
disabled | boolean | undefined | false | Sticking is switched off: the markup is the same, `sticky` is not set. Precisely a prop rather than a `v-if` outside: unmounting would kill the focus on a button of the panel and the state of the nested controls. |
Slots
| Slot | Type | Description |
|---|---|---|
default | { stuck: boolean; } | The content of the panel. `stuck` allows changing it in the stuck state. |
Events
| Event | Signature | Description |
|---|---|---|
stickyChange | (event: "stickyChange", stuck: boolean): void | — |
Methods
| Methods | Signature | Description |
|---|---|---|
stuck | boolean | Whether the panel is stuck right now — the same state as in the slot and in `data-stuck`. |
remeasure | () => void | Re-read the offset and rebuild the observer: `vh` after a resize, a change in the header height. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrAffixProps, GrAffixEmits, GrAffixPlacement } from '@feugene/granularity/components/GrAffix'Filedist/types/components/GrAffix/GrAffix.vue.d.ts
interface GrAffixProps {
placement?: GrAffixPlacement;
offset?: number | string;
disabled?: boolean;
}interface GrAffixEmits {
(e: 'stickyChange', stuck: boolean): void;
}Filedist/types/components/GrAffix/config.d.ts
const grAffixConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrAffix">;Filedist/types/components/GrAffix/affixState.d.ts
type GrAffixPlacement = typeof GR_AFFIX_PLACEMENTS[number];Filedist/types/components/GrAffix/safelist.d.ts
const grAffixSafelist: string[];Filedist/types/components/GrAffix/index.d.ts
type GrAffixInstance = ComponentExposed<typeof GrAffixComponent>;Usage from the library prose
"Stuck" is computed by a probe, not by a guess
Next to the panel stands an invisible sentinel one pixel tall — it marks the
place where the panel would stand were it not sticky, and it does not shift the
layout: the pixel of height is cancelled by a pixel of negative margin. It is
watched by an IntersectionObserver whose root edge is pulled in by exactly the
sticking offset — then the boundary of the root is precisely the line whose
crossing means “stuck”.
This is computed from the rectangles of the entry, not from its
isIntersecting: that one is equally false both when the panel has not yet
reached the edge and when its containing block has ended and it has gone past
it.
The sentinel has exactly one cost, and it is visible in the layout: in a flex or
grid parent it counts as a separate element — it gets its own gap, and in a
grid it also takes up a cell, shifting its neighbours. A panel in such a parent
is wrapped in an ordinary div.
The wrapper here does not contradict the rule above: what is forbidden is a
wrapper of the panel’s height, whereas this one gets its height from the
parent — the grid stretches the cell to the whole row — and so it remains the
containing block inside which the panel has somewhere to travel. The same is
given by an explicit height or align-self: stretch in a flex column.
The offset can be set for a group rather than for each panel
The offset prop is instance data: “this panel stands under a 112-pixel
header”. A shared offset for all the panels of a section is set not by the prop
but by a token on the container or on :root:
:root { --gr-affix-offset: 112px; }The observer reads the computed style, not the value of the prop, so the
cascade, 4rem, var(--gr-navbar-height) and calc(…) work alike and are
picked up alike. An exception to the shared rule is set by the prop on the
specific panel.
An offset that has changed without a change of the prop — after a resize with
vh units, after a change of the header height — is re-read with the
remeasure() method through a ref to the component.
The surface appears together with the sticking
An unstuck panel stands inside the form and must look like a part of it, so the
background and the shadow are switched on only in the stuck look — unlike
GrNavbar and the header of GrTable, which are always opaque.
The background is mandatory: without it the content going under the panel shows right through. It is separated by a shadow, not by a border: a border adds height and would shift the layout exactly at the moment of sticking — the page would jerk by a pixel.
For the same reasons the panel must be the full width of its scrollport. A padding set on the scrollable block leaves margins at its sides — and in those gaps the departing content is visible. The paddings therefore live on the content: inside the panel itself and on its neighbours, not on the shared container.
A panel inside a card takes its surface with a single line:
<GrAffix style="--gr-affix-bg: var(--gr-card)">
…
</GrAffix>With no background at all — --gr-affix-bg: transparent; --gr-affix-shadow: none.
The layer is local, and that is not an omission
The panel sits on z-index: 10 inside its own stacking context, not on a step of
the shared scale. The gap between the steps of the scale is left to the
application precisely for such a case (“my own sticky header under a dropdown”),
and a sticky panel has no global answer to the question “above or below the
bottom navigation” — it depends on the application. If a layer of the scale is
needed: --gr-affix-z: var(--gr-z-navbar).
Several panels under one another
Two top panels in one scroller stick at the same offset and overlap: there is no
automatic stacking — it would require measuring heights on every frame. The
second panel’s offset is set by hand: :offset="112 + heightOfTheFirst". With an
equal layer the one later in the markup ends up on top.
When it does not stick
position: sticky silently stops working if between the panel and the real
scroller there is an ancestor with an overflow other than visible: it becomes
the scrollport itself, and there is nothing to scroll it with. The build is green
at that point, the class stands in the markup, and this cannot be told from a
mistake of one’s own without DevTools — so in dev mode the component names the
guilty ancestor in the console.
Just as quietly the sticking is broken by contain: paint,
contain: strict | content and content-visibility: auto on an ancestor. The
component does not check for those: parsing the values of contain would give
false positives costlier than a missed case. If the panel does not stick and
there is no warning — look at those first.
Limits
- there is no horizontal sticking. The edges are only
topandbottom; a pinned column isGrDataTable, and there it is done differently.
Props in this example