Components · Layout and structure

Sticky footer

An action bar that holds the page's commit step in view as the user scrolls. Two types — fixed and floating — three max-widths, and a mobile-stacked layout. Designed to disappear in the periphery until the user is ready to act.

Documentedby Derek Fidler

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 …

Edge-to-edge bar at the bottom of the viewport. Light surface, 1 px top border, no shadow. Sits on top of scrolling content. Use for the primary path on a long form or a multi-step flow.

Floating · table bulk actions

Products
ProductPrice
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

A pill-shaped surface that sits inside the page margin. Tinted background, 1 px shadow. Reserved for row-selection bulk actions on a table— try checking and unchecking the rows above; the bar mounts the moment the first checkbox flips and unmounts when the last one does. Don't reach for it elsewhere.

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.

  1. Leading slot

    Cancel, summary text, or empty. The lightest weight that still anchors the bar's left edge.

  2. Edge treatment

    Fixed: 1 px top border. Floating: xs shadow + rounded-lg. Never both.

  3. Trailing slot

    The commit action. One primary; tertiary alternatives sit to its left. Overflow goes in a kebab menu.

  4. 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 …

Edge-to-edge with 16 px padding. Two equal-importance buttons fit; for three or more, switch to a single primary plus a 'More' menu.

Mobile · floating

Products
ProductPrice
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

Stacks vertically. Buttons take the full row at the top so they're thumb-reachable; the summary line sits below in 14 px text. Same lifecycle as desktop — the bar appears with the first checkbox and disappears with the last.

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, not fixed. 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-bottom equal 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 when prefers-reduced-motion is 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.

Products
ProductPrice
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

PropTypeDefaultDescription
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.
childrenReactNodeTwo 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-labelstringAccessible name for the landmark. Required when as="footer". Examples: "Page actions", "Bulk actions", "Form actions".
...restHTMLAttributesAll standard HTML attributes pass through.