Sticky Button
A button that remains fixed in the viewport as the user scrolls. Use for persistent primary actions that must remain accessible throughout a long page or form. Don't use more than one Sticky Button per view.
FigmaDescription
Fixes a primary call-to-action button to the bottom of the viewport so it stays visible as the user scrolls through long content. The button overlays page content rather than pushing it, and dismisses or becomes inactive once the action is no longer relevant.
Bottom of long forms, survey pages, consultation submission flows, and multi-section stakeholder engagement pages where the primary action (e.g. Submit, Save, Continue) must remain reachable without scrolling back to the top or bottom of the page.
Government forms and consultation pages are often long and content-heavy. Users — including older adults and those less familiar with digital interfaces — benefit from a persistent, unambiguous call-to-action. The Sticky Button reduces friction and drop-off on high-completion-rate tasks.
Anatomy
| Part | Required? | Notes |
|---|---|---|
| Container | Required | Fixed-position wrapper anchored to the bottom of the viewport. Carries a drop-shadow to visually separate from scrolled content. Width is full on mobile; constrained to match page content width on wider viewports. |
| Button | Required | A single primary Button component inside the container. Inherits all Button variants and states. Should always be the primary variant. |
| Backdrop overlay | Optional | A subtle translucent band behind the container that helps the button read against varied page backgrounds. Use when page content behind the button may be visually complex (e.g. maps, images, data tables). |
| Safe-area inset | Recommended | Bottom padding that accounts for mobile browser chrome and iOS home indicator (env(safe-area-inset-bottom)). Always apply to avoid the button being obscured on mobile devices. |
Variants
Default
Primary action persisted at the bottom of the viewport.
Use for the single most important action on a long page — typically Submit, Save, or Continue.
With count / badge
Communicates pending or selected items relevant to the action.
Use when the action is conditional on selections made on the page, such as 'Submit 3 responses' or 'Add 2 stakeholders'.
States
| State | Behaviour |
|---|---|
| Default | Button is fully interactive. Label reflects the primary action. Drop-shadow and backdrop are visible. |
| Hover | Button background transitions to --op-color-interactive-hover. Cursor changes to pointer. |
| Focus | 3px focus ring using --op-color-interactive-focus appears around the button. Focus is not clipped by the container. |
| Active / pressed | Button background darkens slightly. Provides tactile feedback on touch devices. |
| Disabled | Button opacity reduces to 40%. Cursor is not-allowed. The sticky container remains visible but the action cannot be triggered. Always pair with an inline explanation of why the action is unavailable. |
| Loading | Button label is replaced by a wa-spinner. Button is non-interactive during loading. aria-label is updated to communicate the in-progress state to screen readers. |
| Hidden / scrolled past | If the natural in-page button position scrolls into view, the sticky button should be hidden to avoid duplication. This is managed via an IntersectionObserver on the in-page anchor element. |
Usage guidelines
When to use
- Long consultation or survey forms where the submit action would otherwise require scrolling to find.
- Multi-section stakeholder engagement pages with a single terminal action (e.g. finalise engagement plan, publish consultation).
- Step-within-page flows where the user must scroll to review content before confirming.
When not to use
- Short pages where the action is already visible without scrolling — use a standard Button instead.
- When multiple primary actions are needed — sticky buttons must be singular. Use a standard button group or action bar instead.
- Inside modal dialogs or drawers — use standard Button placement within those components.
- When the action is destructive (e.g. Delete, Archive) — destructive actions should require deliberate navigation, not a persistent visible trigger.
Do / Don't
Do
Use a single, specific action label (e.g. 'Submit consultation', 'Save stakeholder record').
Don't
Use vague labels like 'OK' or 'Click here'.
Do
Hide the sticky button when the in-page equivalent button scrolls into the viewport.
Don't
Show both the sticky and in-page versions simultaneously — this confuses users and clutters the UI.
Do
Apply safe-area-inset-bottom padding so the button clears mobile browser chrome.
Don't
Hard-code bottom: 0 without the env() safe area inset — it will be obscured on iOS devices.
Do
Disable the button with an accessible reason when prerequisites are not met (e.g. required fields incomplete).
Don't
Hide the button entirely when prerequisites are not met — this removes user orientation.
Do
Keep the container z-index high enough to overlay content but lower than modal/dialog overlays.
Don't
Set z-index arbitrarily high (e.g. 9999) — use the design system z-index scale.
Layout & Spacing
Container: position: fixed bottom: 0 left: 0 right: 0 padding: --op-space-12 (12px) --op-space-16 (16px) padding-bottom: calc(--op-space-12 + env(safe-area-inset-bottom))
On viewports >= 768px, constrain container width to match page content max-width and centre-align.
Button within container: width: 100% on mobile width: auto (min-width: 200px) on desktop, right-aligned or centred per layout height: minimum 44px (--op-space-44) to meet touch target requirement
Backdrop overlay (optional): height: --op-space-80 (80px) gradient fade above container background: linear-gradient(transparent, --op-color-bg-primary)
Drop-shadow: box-shadow: 0 -2px 8px rgba(0,0,0,0.10)
Tokens
| Part | Token | Value |
|---|---|---|
| Container background | --op-color-bg-primary | Matches the page background so the container looks like a raised surface, not a coloured bar. |
| Button (primary) | --op-color-interactive-default | Green-400. The button uses standard Button tokens — do not override at the sticky container level. |
| Button hover | --op-color-interactive-hover | Applied by the Button component internally. |
| Focus ring | --op-color-interactive-focus | 3px solid, applied by the Button component. Ensure the container does not clip overflow. |
| Disabled opacity | opacity: 0.4 | Applied to the Button element. The container itself does not change. |
| Border top | --op-color-border-default | 1px top border on the container to visually anchor it. |
| Safe-area padding | env(safe-area-inset-bottom) | Not an --op-* token. Always apply via calc() on top of --op-space-12 bottom padding. |
Engineering notes
- Use an IntersectionObserver on the in-page button (or a sentinel element at the natural button position) to toggle a .is-hidden class on the sticky container. This prevents double-button confusion.
- The sticky container must not be a child of any overflow: hidden or overflow: auto ancestor, or position: fixed will not work correctly.
- Apply aria-hidden='true' to the sticky button container when it is hidden via .is-hidden. Do not rely solely on CSS visibility — screen readers may still announce hidden elements.
- When the button enters loading state, set aria-disabled='true' and update aria-label to e.g. 'Submitting, please wait'. Remove these attributes when loading completes.
- The component is implemented as a native HTML button wrapped in a positioned div — it does not use a wa-* web component at the container level. The inner button may use wa-button.
- Z-index: use the design system z-index scale. Sticky UI should sit above page content but below dialogs and toasts. A value of 100–200 is typical.
- For forms using native HTML form submission, ensure the sticky button carries type='submit' and is a descendant of the form element, or use a form attribute pointing to the form's id.
Keyboard interaction
| Key | Action |
|---|---|
| Tab | Moves focus to the sticky button when it is visible and not disabled. The button participates in the natural tab order. |
| Enter | Activates the button action (same as click). |
| Space | Activates the button action (same as click). |
Why it matters
Government digital services must be usable by people with motor disabilities who rely on keyboard navigation, and by screen reader users who may not have a visual sense of page scroll position. A sticky button that is not properly announced or keyboard-accessible creates a critical barrier on submission flows.
Focus
The sticky button must be reachable via Tab in the natural document order. Do not use tabindex='-1' unless the button is genuinely hidden (aria-hidden='true').
When the button transitions from hidden to visible, focus should not be programmatically moved to it — moving focus unexpectedly disorients screen reader users. Allow the user to Tab to it naturally.
The focus ring (3px, --op-color-interactive-focus) must not be clipped by the container's overflow. Set overflow: visible on the container, or add sufficient padding around the button to accommodate the ring.
When the button enters loading state, focus should remain on the button. After the action completes (success or error), move focus to the relevant feedback element (e.g. success banner, error summary).
ARIA
| Role or attribute | When to use | Example |
|---|---|---|
aria-label | Use when the visible button label alone is insufficient, or when the label changes state during loading. | <button aria-label="Submitting, please wait" aria-disabled="true"> |
aria-disabled | Apply when the button is visually disabled but must remain focusable so users understand why they cannot proceed. Pair with a visible or announced reason. | <button aria-disabled="true" aria-describedby="submit-hint"> |
aria-hidden | Apply to the sticky button container (not the button itself) when it is visually hidden, to remove it from the accessibility tree. | <div class="sticky-button is-hidden" aria-hidden="true"> |
aria-live | Not applied to the button itself. Use an aria-live='polite' region elsewhere on the page to announce loading state changes and submission results. | <div role="status" aria-live="polite" id="form-status"></div> |
Contrast
Button label on --op-color-interactive-default (green-400) must meet 4.5:1 contrast ratio (WCAG 2.1 AA, normal text) or 3:1 (large text / bold 18px+).
Confirm the specific green-400 value against --op-color-text-on-interactive in your token file. If the ratio is below 4.5:1 for normal-weight labels, use --op-color-text-on-interactive (typically white or near-white) as the label colour.
Disabled state at 40% opacity is an accepted WCAG exception for disabled controls (WCAG 1.4.3), but always provide a visible non-colour cue (e.g. muted label, not-allowed cursor) alongside the opacity reduction.
Touch targets
The button within the sticky container must have a minimum touch target of 44x44px (WCAG 2.5.5). On mobile, set the button to full width of the container to maximise the target area. Do not reduce height below 44px for aesthetic reasons. The safe-area-inset-bottom padding ensures the touch target is not obscured by iOS home indicator or Android gesture navigation.
Things to avoid
- Do not use the sticky button for destructive or irreversible actions (delete, revoke access). These require deliberate user navigation.
- Do not auto-submit a form when the sticky button becomes visible — it must always require a deliberate user action.
- Do not animate the sticky button entrance in a way that triggers motion sickness. Respect prefers-reduced-motion and reduce or eliminate entrance animation.
- Do not place the sticky button over important page content such as a terms-of-service checkbox the user must interact with before submitting.
- Do not use colour alone to communicate the button's disabled state — pair opacity with a text or icon cue.