Cart
- Premium POS terminal · 1 × € 0,00
- Pro POS tablet · 1 × € 0,00
- A920 Pro mobile terminal · 1 × € 0,00
… page content scrolls behind …
Overview
A sticky footer pins the page's primary commit action to the bottom of the viewport so a long scroll never separates the user from “Save”. The bar lives above the page content, takes its own background, and uses a 1 px top border or a subtle shadow to mark its edge.
Reach for it sparingly
Most pages don't need a sticky footer. Reach for one only when (1) the page can't fit its actions above the fold, (2) the user is mid-flow and the action terminates the flow, or (3) the user is selecting rows in a table and needs persistent access to the bulk actions while they scroll. The third case is the only place the floating variant belongs — see Types below.
Types
Two layout variants, picked by the job. Fixed is the structural case — the primary path on a long form. Floating is reserved for one specific job: row-selection bulk actions on a table. The bar appears the moment the user checks a row, holds the selection summary and the actions that operate on it, and disappears the moment the selection clears. Don't reach for the floating variant for unsaved-draft alerts, announcements, or any other transient surface — those need a different pattern.
Fixed · default
Cart
- Premium POS terminal · 1 × € 0,00
- Pro POS tablet · 1 × € 0,00
- A920 Pro mobile terminal · 1 × € 0,00
… page content scrolls behind …
Floating · table bulk actions
| Product | Price | |
|---|---|---|
| Premium POS terminal | € 1,299.00 | |
| Pro POS tablet | € 899.00 | |
| A920 Pro mobile terminal | € 549.00 | |
| Z11 PIN pad | € 199.00 | |
| Receipt printer · TM-T20 | € 129.00 | |
| Cash drawer · CD-501 | € 89.00 |
3 products selected
Anatomy
Four named parts. The container carries the surface and the edge treatment; everything inside is two slot regions split by justify-between. The inner row honours the page's max-width — same gutters as the rest of the page so actions land under the form column they belong to.
Leading slot
Cancel, summary text, or empty. The lightest weight that still anchors the bar's left edge.
Edge treatment
Fixed: 1 px top border. Floating:
xsshadow +rounded-lg. Never both.Trailing slot
The commit action. One primary; tertiary alternatives sit to its left. Overflow goes in a kebab menu.
Container
The surface. Edge-to-edge on fixed; inset by the page gutter on floating. Carries the inner row's max-width.
Max-width
Three inner widths cover almost every page in the product. The container is always edge-to-edge; the inner row honours the chosen width. Pick the value that matches the page's content column — don't set a different width for the bar than the form above it. The Save button has to sit directly under the field it commits; a mismatch between the content column and the action bar leaves the eye chasing it across the page.
- Full — actions stretch to the page edges minus the gutter. Use when the form fills the viewport (settings, bulk edit).
- 7xl (1280 px) — matches the standard product page content column. Default for most flows.
- 4xl (896 px)— narrow forms (account, single-task wizards). Actions align with the form's reading column.
Match the bar to the page, not the other way around
If your page's content column is max-w-7xl, the bar is maxWidth="7xl". If your page is max-w-4xl, the bar is maxWidth="4xl". They should always agree. The rule holds even when the bar seems to have plenty of room: a Save button under empty page gutter is harder to find than one anchored beneath the last field.
Mobile
On phones the bar still pins to the bottom of the viewport, but the interior layout adapts. Fixed keeps the left/right split with both buttons at full 48 px height — thumb reach over visual symmetry. Floating stacks the actions on top of the summary line so the buttons claim the tap-priority row.
Mobile · fixed
Cart
- Premium POS terminal · 1 × € 0,00
- Pro POS tablet · 1 × € 0,00
- A920 Pro mobile terminal · 1 × € 0,00
… page content scrolls behind …
Mobile · floating
| Product | Price | |
|---|---|---|
| Premium POS terminal | € 1,299.00 | |
| Pro POS tablet | € 899.00 | |
| A920 Pro mobile terminal | € 549.00 | |
| Z11 PIN pad | € 199.00 | |
| Receipt printer · TM-T20 | € 129.00 | |
| Cash drawer · CD-501 | € 89.00 |
3 products selected
Two-action ceiling on mobile
Mobile sticky footers carry at most two visible actions. Anything extra (Save draft, Discard, Duplicate) collapses into a More menu reachable from the bar.
Behavior
- Use
position: sticky, notfixed. Sticky positions the bar relative to its scroll container so the footer sits below content when the page is short, and pins to the viewport bottom when the page scrolls. Fixed positioning works but covers content on short pages. - Reserve scroll padding. Add
padding-bottomequal to the footer's height on the page's scroll container so the last form field can scroll above the bar instead of getting stuck behind it. - Don't animate the bar in and out on scroll. The bar is either present or absent — never sliding away to recover screen real estate. Hide-on-scroll patterns confuse users about where the action lives.
- Floating bars enter and exit with their context. When the user selects rows, the bar slides up from the bottom in 200 ms; when the selection clears, it slides back down. No dismiss button — the trigger handles its own lifecycle.
- Honour reduced-motion. The 200 ms enter/exit on floating bars is a fade + small
translateY. Cut the translate whenprefers-reduced-motionis set; keep the fade.
Accessibility
- Landmark: wrap the bar in a
<footer>with an accessible name (aria-label="Page actions"or similar). Screen readers can jump to it as a landmark. - Tab order:the bar comes after the page content in DOM order so keyboard users naturally land on the commit action after stepping through the form. Don't hoist it to the top of the DOM for visual reasons.
- Disabled commit:when the action isn't ready (form invalid, nothing selected), use
aria-disabled="true"so the button stays focusable and screen readers announce the state. Pair with helper text explaining what's missing. - Floating bars announce themselves: when a bulk selection appears, surface the bar via an
aria-live="polite"region with the count and the available actions. Don't rely on the visual entrance alone. - Focus trap:when the bar opens a confirmation (e.g., destructive primary), trap focus inside the dialog until it's resolved. The bar itself doesn't trap focus — the page is still navigable.
Code
The StickyFooter ships from @flatpay-dk/ui. It owns the layout (sticky position, surface, max-width) and composition (slot grammar). The actual buttons come from the Button component.
tsx
import { StickyFooter, Button } from "@flatpay-dk/ui";
// Default — fixed bar, full width, cancel + primary
<StickyFooter>
<Button variant="tertiary" onClick={cancel}>Cancel</Button>
<Button variant="primary" onClick={save}>Save changes</Button>
</StickyFooter>
// Match the bar's maxWidth to the page's content column.
// If the form is wrapped in max-w-4xl, the bar is maxWidth="4xl".
<main className="mx-auto w-full max-w-4xl px-6">
{/* …form fields… */}
</main>
<StickyFooter maxWidth="4xl">
<Button variant="tertiary">Cancel</Button>
<Button variant="primary">Continue</Button>
</StickyFooter>
// Floating — table bulk actions only. Mount the bar when a row is
// selected, unmount when the selection clears. Don't leave it
// mounted with zero rows checked.
{selected.size > 0 && (
<StickyFooter type="floating" aria-label="Bulk actions">
<p>{selected.size} products selected</p>
<div className="flex items-center gap-3">
<Button variant="secondary" onClick={() => exportRows(selected)}>
Export
</Button>
<Button variant="primary" onClick={() => archive(selected)}>
Archive
</Button>
</div>
</StickyFooter>
)}
// Disabled commit while the form is invalid
<StickyFooter>
<Button variant="tertiary" onClick={cancel}>Cancel</Button>
<Button
variant="primary"
onClick={save}
disabled={!form.isValid}
aria-disabled={!form.isValid}
>
Save changes
</Button>
</StickyFooter>Best practices
The bar holds the commit step. Treat it like the most important surface on the page, because for most users that's exactly what it is.
Cart
- Premium POS terminal · 1 × € 0,00
- Pro POS tablet · 1 × € 0,00
- A920 Pro mobile terminal · 1 × € 0,00
… page content scrolls behind …
Do
One primary commit, optionally paired with a tertiary cancel. Clear hierarchy; the eye lands on the primary first.
Cart
- Premium POS terminal · 1 × € 0,00
- Pro POS tablet · 1 × € 0,00
- A920 Pro mobile terminal · 1 × € 0,00
… page content scrolls behind …
Don't
Don't pair two primaries. They cancel each other; the user reads neither and clicks the wrong one.
| Product | Price | |
|---|---|---|
| Premium POS terminal | € 1,299.00 | |
| Pro POS tablet | € 899.00 | |
| A920 Pro mobile terminal | € 549.00 | |
| Z11 PIN pad | € 199.00 | |
| Receipt printer · TM-T20 | € 129.00 | |
| Cash drawer · CD-501 | € 89.00 |
3 products selected
Do
Use floating only for row-selection bulk actions on a table. The bar appears the moment a row is checked and unmounts when the selection clears.
Cart
- Premium POS terminal · 1 × € 0,00
- Pro POS tablet · 1 × € 0,00
- A920 Pro mobile terminal · 1 × € 0,00
… page content scrolls behind …
Always-on settings
Don't
Don't reach for floating outside the table-bulk-actions case. A persistent settings bar, an unsaved-draft alert, an announcement — all of these need a different pattern.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| type | "fixed" | "floating" | "fixed" | Layout variant. Fixed = edge-to-edge with a 1 px top border. Floating = inset, rounded, with an xs shadow. |
| maxWidth | "full" | "7xl" | "4xl" | "full" | Inner row width. Set this to match the page's content column — same value as the form's max-width container. The bar's outer surface is always edge-to-edge for fixed (or inset by the page gutter for floating); the maxWidth governs only where the actions sit so they land under the field they commit. |
| children | ReactNode | — | Two slot regions split by justify-between (desktop) or stacked (mobile floating). Pass leading content first, trailing actions second. |
| as | "footer" | "div" | "footer" | Element rendered. Use "footer" for the page's primary commit bar so screen readers can use it as a landmark. "div" for transient floating bars that aren't the page footer. |
| aria-label | string | — | Accessible name for the landmark. Required when as="footer". Examples: "Page actions", "Bulk actions", "Form actions". |
| ...rest | HTMLAttributes | — | All standard HTML attributes pass through. |