Sticky CTA Bar React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React sticky CTA bar for product, listing and checkout pages on phones: the price on one side, the main action on the other.
The bar sits at the bottom edge, above a bottom tab bar and the home indicator, with the price (an old price struck through, a muted caption) on the left and the main button on the right, plus an optional icon button such as a heart. With showOn="anchor" it stays out of the way while the page's own buy button is on screen and glides up on a smooth spring once you scroll past it (or while you have not reached it yet), then drops away quickly when the button comes back; showOn="scroll" does the same after a scroll distance. With hideOnScroll it follows your finger down and out while you scroll down and comes back as soon as you scroll up. Tap the button: a promise from onAction shows its spinner without changing the width. When the price changes the new amount fades in. When the on-screen keyboard opens (a coupon field) the bar slides away by default, or rides on top of the keyboard with keyboard="lift". Everything is a normal button, reached with Tab and pressed with Enter or Space; while hidden the bar is inert so Tab skips it, and while keyboard focus is inside it never hides. Under reduced motion it fades instead of sliding.
Sticky CTA Bar is a Pro React component in the React mobile components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add sticky-cta-bar, together with Button, Number format, useLocale, useScrollState, useKeyboardInset and 3 more and the npm package motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add sticky-cta-barSticky CTA Bar is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import StickyCtaBar from @/components/ui/sticky-cta-bar.
import { Heart } from "lucide-react"
import { Button } from "@/components/ui/button"
import { StickyCtaBar } from "@/components/ui/sticky-cta-bar"
export function ListingBuyBar({ onBuy }: { onBuy: () => Promise<void> }) {
return (
<StickyCtaBar
price={349}
compareAtPrice={449}
caption="Tax included · Free shipping"
locale="en-US"
actionLabel="Add to cart"
tone="brand"
onAction={onBuy}
secondaryAction={
<Button variant="soft" size="icon" radius="full" aria-label="Save">
<Heart />
</Button>
}
labels={{ region: "Purchase", compare: "Old price" }}
/>
)
}Props
pricedefault0numberThe amount, formatted with locale and currency ("$489,000", "$1,284.90"). 0 hides the price row; pass summary for "Free" or anything else.
currencydefault"USD"stringISO 4217 code, shown with the narrow symbol ($, €, £). Empty shows the plain number.
localedefault""stringIntl locale for the price, like en-US. Empty uses the UIProvider locale, then the page's language. The demo uses en-US.
priceSuffixdefault""stringSmaller muted text right after the price: "/mo", "/night".
compareAtPricedefault0numberThe old price, struck through at the start of the line under the price and read as "Was …" by screen readers. Shown only when it is above price.
badgeReactNodeA small badge after the price, like <Badge tone="danger" variant="solid" size="sm">−25%</Badge>. It gives way to the price when a very narrow phone has no room for both.
captiondefault""stringOne muted line under the price, like "Tax included · Free shipping". Hidden on phones under 360px wide.
actionLabeldefault"Continue"stringThe primary button's text. On a narrow phone a long label truncates before the price is ever cut.
summaryReactNodeReplaces the whole price block: "2 tickets · Madison Square Garden", a seller, a sale badge.
actionIconReactNodeAn icon after the primary button's label, like an arrow.
onAction(() => void | Promise<unknown>)Runs on the primary button. Return a promise and the button shows its spinner (same width, aria-busy) until it settles.
actionPropsOmit<ButtonProps, "children" | "onClick" | "size" | "tone" | "radius" | "color">Anything else for the primary Button: leftIcon, type="submit" with form, aria-*.
actionReactNodeReplaces the primary button entirely, for example a <Button asChild> link to the checkout.
secondaryActionReactNodeAn icon button before the primary: <Button variant="soft" size="icon" radius="full" aria-label="Save">. Match its size to the bar (icon-sm / icon / icon-lg).
variantdefault"bar""bar" | "floating"bar: an edge-to-edge glass bar with a hairline on top. floating: an inset rounded card with a floating shadow, 12px above the safe area.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The primary button's color. Brand orange suits the one checkout action.
colordefault""stringAny CSS color for the primary button. Replaces the tone; the text turns black or white by lightness.
sizedefault"md""sm" | "md" | "lg"Button size 32 / 40 / 48px (44px hit areas on touch), bar content height 56 / 64 / 72px, price 16 / 17 / 19px.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"The buttons' corner shape, scaled with the size like the button's.
loadingdefaultfalsebooleanThe primary button's spinner. It keeps its width and focus and ignores presses.
disableddefaultfalsebooleanDims and blocks the primary action.
showOndefault"always""always" | "scroll" | "anchor"always: on screen from the start. scroll: after threshold px of scrolling. anchor: while anchorRef (the page's own buy button) is out of view, not reached yet or scrolled past. Anything but always renders hidden on the server, so it never flashes.
thresholddefault200numberScroll distance in px before the bar appears with showOn="scroll".
anchorRefRefObject<HTMLElement | null>The in-page buy button for showOn="anchor", watched with an IntersectionObserver on the scroll container. Without it the bar shows always (with a dev warning).
hideOnScrolldefaultfalsebooleanSlides away with the finger while scrolling down and comes back when scrolling up and at the end of the page.
keyboarddefault"hide""hide" | "lift" | "none"While the on-screen keyboard is open: hide keeps a coupon field uncovered, lift rides on top of the keyboard (chat-like flows), none does nothing.
positiondefault"fixed""fixed" | "sticky" | "absolute"fixed: to the viewport, above a bottom tab bar (bottom: var(--bottom-bar-offset)). sticky: the last child of a scroll container (the demo's phone frame). absolute: inside a relative box.
hideFromdefault"none""none" | "md" | "lg"Hides the bar with CSS from this breakpoint up, where the page shows its own CTA column.
safeAreadefaulttruebooleanPads the home indicator area, minus what a bottom tab bar below already covers (no double padding), and the side insets of a landscape phone. env() is 0 on desktops.
scrollTargetHTMLElement | Window | RefObject<HTMLElement | null> | nullWhat scrolls, for showOn, hideOnScroll and the anchor. Default: the nearest ScrollContainerProvider, else the window.
labelsPartial<StickyCtaBarLabels>Screen reader strings: region (the landmark name, "Purchase"), compare (read before the old price, "Was"). DEFAULT_STICKY_CTA_BAR_LABELS holds the English defaults.
classNamesPartial<Record<"inner" | "summary" | "price" | "suffix" | "compare" | "badge" | "caption" | "actions" | "secondary" | "primary", string>>Extra classes for the inner parts.
classNamestringExtra classes for the root. The bar writes --cta-bar-offset (its visible height in px, 0 while hidden) on <html> when fixed, else on its parent, so floating buttons can sit above it.
Parts
DEFAULT_STICKY_CTA_BAR_LABELS
constantThe English screen reader strings: the landmark name ("Purchase") and the word read before the old price ("Was").
Examples
Checkout
A brand Pay button with a 1.4s request, a coupon that changes the total, and the keyboard behavior toggle.
Floating tickets
variant="floating" with a summary in place of the price.
Sale
price, compareAtPrice and a −25% badge.
Subscription
A /mo suffix and a price that fades when it changes.
Above a bottom tab bar
Both sticky; --cta-bar-offset and --bottom-bar-offset read live.
Show on scroll
showOn="scroll" with hideOnScroll on a listing feed.
Desktop CTA column
hideFrom="md" hands over to the page's own sidebar card.
Dependencies
Files
- components/ui/sticky-cta-bar.tsx
FAQ
Is the React Sticky CTA Bar component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Sticky CTA Bar?
Run npx wingo-ui@latest add sticky-cta-bar. It also adds the Wingo UI items it builds on (Button, Number format, useLocale, useScrollState, useKeyboardInset and 3 more) and the npm package motion. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/sticky-cta-bar.
See also
Used in
More React mobile components
Articles using this component
- React Checkout Page: Cart, Steps, Coupons and a Pay Bar
Build it: screens and apps12 min read
- React Floating Action Button: Speed Dial and Safe Areas
Mobile UI in React9 min read
- Mobile-First React Components: 9 Rules With Code
Mobile UI in React21 min read
- Tailwind Safe Area Insets: Bottom Bars, FABs and Toasts
Mobile UI in React12 min read


