Sticky CTA Bar
A conversion bar that arrives once a sentinel element — usually the hero's own CTA — leaves the viewport, docks to the bottom or top edge, and stays dismissed for the rest of the session.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/sticky-cta-bar.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "StickyCtaBar" component (lucide-react for
icons, no animation library — CSS keyframes only).
Contract
- Export a forwardRef<HTMLDivElement, StickyCtaBarProps>. The ref, className and
the rest of React.HTMLAttributes<HTMLDivElement> land on the *bar panel*, which
only exists while the bar is revealed (the ref is null while it is away).
- Content: headline: ReactNode (required), description?: ReactNode,
secondary?: ReactNode (a price / countdown slot), icon?: ReactNode (a default
sparkle badge; pass null for none).
- Action: actionLabel?: string (default "Get started"), actionHref?: string —
when present the action renders as a real <a href> (middle-clickable),
otherwise a <button type="button">; onAction?: () => void fires for both,
before navigation. The consumer owns what the action actually does.
- Trigger: sentinel?: RefObject<HTMLElement | null> — the element whose exit
from view arms the bar (usually the hero's own CTA); root?:
RefObject<HTMLElement | null> — a scroll container to observe against, omit
for the viewport; rootMargin?: string — moves the trip line (e.g. "-25% 0px
0px 0px" to fire a quarter-viewport early); once?: boolean (default false) —
stay revealed even when the sentinel returns to view.
- Placement: position?: "bottom" | "top" (default "bottom"); anchor?:
"viewport" | "container" (default "viewport") — fixed to the browser viewport,
or absolute inside the nearest positioned ancestor (docs previews, in-app panels).
- Dismissal: dismissible?: boolean (default true), onDismiss?: () => void,
storageKey?: string | null (default "sticky-cta-bar") — the sessionStorage key
the dismissal is remembered under; null keeps it in memory only.
- label?: string (default "Call to action") — the region's accessible name and
the stem of the polite announcement.
Behavior
- One IntersectionObserver on the sentinel, threshold 0, root/rootMargin from
props. Not intersecting -> armed; intersecting -> retract again unless `once`,
in which case disconnect on the first reveal. Disconnect the observer on
unmount and whenever sentinel/root/rootMargin/once/dismissed change. No scroll
listener anywhere: the browser owns the threshold math, so there is nothing to
rAF-throttle and nothing firing per frame.
- Degrade loudly in the safe direction: with no sentinel *prop*, or in a browser
with no IntersectionObserver, reveal the bar instead of hiding it forever.
Schedule that reveal on a requestAnimationFrame (cancelled in the effect
cleanup) rather than calling setState straight from the effect body.
- Branch on the prop, not on the node: "a sentinel was passed but its ref has not
attached yet" (a hero behind next/dynamic, a Suspense boundary or a data fetch,
while the bar mounts with the shell on the first commit) is *not* the
no-sentinel case. A RefObject's identity never changes, so an effect that
revealed there would never re-run and the bar would sit over the very element
it is waiting for, forever. Poll `sentinel.current` on animation frames
(cancelled in the effect cleanup) until the node lands, then observe it.
- Visibility is *mount-driven*: revealed = the panel is in the DOM, away = it is
not. Never a hidden-but-focusable strip that Tab can fall into. Entrance and
exit are plain CSS keyframe animations on the mounted panel; the exit unmounts
on animationend (ignore animationend bubbled from decorative children), and the
panel carries `inert` while it is leaving so Tab and screen readers stay out of
a bar that is already on its way off screen. Keep the keyframes in a hoisted
<style href precedence> rendered from the *always-mounted* positioning shell,
not from the panel, so they exist before the first frame and survive every
retract/return cycle.
- prefers-reduced-motion, read through matchMedia with a change listener that is
cleaned up (useSyncExternalStore is the tidy way, and it gives an SSR-safe
server snapshot for free): under reduce, skip both animations — the bar appears
and disappears instantly and the exit unmounts immediately rather than waiting
for an animationend that will never come. Handle the mid-exit flip too: if
reduce switches on while the exit is playing, finish the exit from a render-time
state adjustment.
- Dismissal writes "1" to sessionStorage under storageKey inside try/catch
(private mode can throw on read *and* write) and calls onDismiss. Once
dismissed, the observer is not even created. Read the stored flag through
useSyncExternalStore with a server snapshot of false, so nothing is read during
SSR and hydration cannot mismatch.
- Escape while focus is inside the bar dismisses it (only when dismissible) —
scoped to the bar's own onKeyDown, never a window listener, because Escape
elsewhere on the page is not this component's to take. Compose, don't replace,
a consumer-supplied onKeyDown / onAnimationEnd / onFocus.
- Dismissal must not drop focus. Both dismissal paths run with focus inside a
panel that is about to go `inert` or unmount, so activeElement would fall to
<body> and the next Tab would restart at the top of the page. Remember where
focus came from when it entered the panel (the focusin relatedTarget, ignoring
moves within the bar) and, if that element is still `isConnected`, give it
focus({ preventScroll: true }) before flipping the dismissed state — never
scroll the page back on the way out.
- Announce politely from a persistent sr-only role="status" region that lives
*outside* the bar's own mount cycle: "<label> available" the first time it
appears, "<label> dismissed" when the visitor closes it. Never re-announce on
retract/return — a reader scrolling up and down must not be nagged every pass.
The bar itself is role="region" + aria-label, not a live region.
Rendering & styling
- Semantic tokens only: bg-background/85 + backdrop-blur-md + border-border +
shadow-lg for the surface, bg-primary/text-primary-foreground for the action,
text-muted-foreground for the supporting line, hover:bg-accent for the dismiss
button, focus-visible:ring-ring everywhere. Decorative colour comes from
var(--chart-1/2/4/5) through color-mix — an accent hairline along the docked
edge and a soft two-corner wash — both aria-hidden, both purely decorative.
- Layout: a pointer-events-none positioning shell (fixed or absolute, inset-x-0,
bottom-0 or top-0) holding a pointer-events-auto panel, so the empty strip
around the bar never eats a tap or blocks touch scrolling. Inside, a centered
max-w-5xl row: icon badge, headline + description, secondary slot, action,
dismiss. Below sm the badge, description and secondary slot drop out so the
bar stays a single row on phones; the headline truncates.
- Safe area: the inner row's padding is set in inline style as
calc(0.75rem + env(safe-area-inset-bottom, 0px)) on the docked edge and
calc(1rem + env(safe-area-inset-left/right, 0px)) horizontally, so the panel's
background still bleeds to the physical edge while the content clears the home
indicator and the landscape notch. env() only resolves to a non-zero value once
the page's viewport meta opts in with viewport-fit=cover (in Next.js:
`export const viewport = { viewportFit: "cover" }` in the root layout) —
without it every inset is 0px and the buttons sit under the home indicator on
iOS, so ship that opt-in together with the bar.
- Coarse pointers get 44px targets (pointer-coarse:h-11 on the action,
pointer-coarse:size-11 on the dismiss button) with no JS. There is no
pointer-tracking effect to degrade — the only hover flourish is a 2px arrow
nudge, which also fires on focus-visible so keyboard users get the same cue.
- Expose data-state="visible" | "leaving" on the panel for consumers who want to
style the transition themselves.
Customization levers
- Trip line: swap the sentinel (hero CTA vs the whole hero vs a pricing block),
or keep it and shift rootMargin to fire earlier/later; drop the sentinel
entirely for a plain always-on pinned bar.
- Persistence: storageKey per campaign so a new offer asks again; null for a bar
that returns on every page view; swap sessionStorage for localStorage if the
dismissal should outlive the tab (that is a product decision, not a styling one).
- Return behaviour: once={true} for "arrive and stay", the default false for a
bar that steps aside whenever the hero CTA is on screen again.
- Density: max-w-5xl and the 0.75rem/1rem padding are the whole layout budget —
raise them for a roomier bar, or drop the description and the icon badge for a
one-line strip.
- Surface: bg-background/85 + backdrop-blur reads as glass; swap to bg-card for
opaque, or bg-primary/text-primary-foreground for a loud bar (then flip the
action to a secondary/outline treatment so it still stands out).
- Decoration: the chart-token hairline and wash are two aria-hidden spans —
delete them for a flat bar, or re-point them at different --chart-* tokens.
- Slots: `secondary` takes a price, a countdown or a trust line; `icon` takes a
logo mark or null; both can be dropped without touching the state machine.Concepts
- Sentinel-driven reveal — the trip line is an element, not a pixel offset: watching the hero's own CTA is exact on every viewport and survives copy changes that would break a hardcoded
scrollY > 800. - Retract on return — by default the bar steps aside whenever the sentinel is back on screen (the offer is already right there);
onceturns that off for a bar that arrives and stays. - Session-scoped dismissal — closing writes one
sessionStorageflag, so the offer is gone for the rest of the tab but not forever; a newstorageKeyper campaign lets the next offer ask again. - Mount-driven visibility — revealed means "in the DOM", away means "not in the DOM". No hidden-but-tabbable strip, and no way for a skipped animation to leave the CTA invisible: reduced motion simply removes the travel.
- Announce once, not per pass — a persistent
role="status"region outside the bar's mount cycle announces the arrival and the dismissal only; scrolling up and down re-mounts the bar without re-announcing it. - Edge docking with safe area — the panel's background bleeds to the physical edge while its content is padded by
env(safe-area-inset-*), so the bar reads as flush on desktop and still clears the home indicator and landscape notch on phones. One prerequisite lives on the page, not in the component:env(safe-area-inset-*)resolves to0pxuntil the viewport meta saysviewport-fit=cover(in Next.js,export const viewport = { viewportFit: "cover" }in the root layout) — without that opt-in the padding is a no-op and the action sits under the iOS home indicator. - Focus handed back on dismiss — closing the bar (Escape or the close button) happens with focus inside a panel that is about to go
inertor unmount, so the component returns focus to whatever element it entered from, withpreventScroll, instead of letting it fall to<body>and making the next Tab restart at the top of the page.