Navigation

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowRight, Sparkles, X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Entrance, exit and the one-shot arrival sheen. Ships inside the component via a
 * React 19 hoisted <style href> — no tailwind config edits, and duplicates dedupe
 * by href. Entrance/exit are plain CSS animations on a mounted element, so the bar
 * is visible *because it is mounted*, never "visible only if an animation ran".
 */
const KEYFRAMES = `@keyframes scb-in-bottom{from{transform:translateY(105%);opacity:0}to{transform:translateY(0);opacity:1}}
@keyframes scb-out-bottom{from{transform:translateY(0);opacity:1}to{transform:translateY(105%);opacity:0}}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/sticky-cta-bar.json

Prompt

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); once turns that off for a bar that arrives and stays.
  • Session-scoped dismissal — closing writes one sessionStorage flag, so the offer is gone for the rest of the tab but not forever; a new storageKey per 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 to 0px until the viewport meta says viewport-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 inert or unmount, so the component returns focus to whatever element it entered from, with preventScroll, instead of letting it fall to <body> and making the next Tab restart at the top of the page.

On This Page