# Sticky CTA Bar

> A React sticky CTA bar for product, listing and checkout pages on phones: the price on one side, the main action on the other.

- Type: React component (Mobile)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add sticky-cta-bar`
- Docs: https://wingo-ui.com/components/sticky-cta-bar

## Interaction

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.

## Installation

```bash
npx wingo-ui@latest add sticky-cta-bar
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/sticky-cta-bar` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/sticky-cta-bar.tsx`

npm dependencies: `npm i motion`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))
- [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) ([Markdown](https://wingo-ui.com/components/use-keyboard-inset.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `price?` | `number` | `0` | The amount, formatted with locale and currency ("$489,000", "$1,284.90"). 0 hides the price row; pass summary for "Free" or anything else. |
| `currency?` | `string` | `"USD"` | ISO 4217 code, shown with the narrow symbol ($, €, £). Empty shows the plain number. |
| `locale?` | `string` | `""` | Intl locale for the price, like en-US. Empty uses the UIProvider locale, then the page's language. The demo uses en-US. |
| `priceSuffix?` | `string` | `""` | Smaller muted text right after the price: "/mo", "/night". |
| `compareAtPrice?` | `number` | `0` | The 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. |
| `badge?` | `ReactNode` |  | A 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. |
| `caption?` | `string` | `""` | One muted line under the price, like "Tax included · Free shipping". Hidden on phones under 360px wide. |
| `actionLabel?` | `string` | `"Continue"` | The primary button's text. On a narrow phone a long label truncates before the price is ever cut. |
| `summary?` | `ReactNode` |  | Replaces the whole price block: "2 tickets · Madison Square Garden", a seller, a sale badge. |
| `actionIcon?` | `ReactNode` |  | An 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. |
| `actionProps?` | `Omit<ButtonProps, "children" \| "onClick" \| "size" \| "tone" \| "radius" \| "color">` |  | Anything else for the primary Button: leftIcon, type="submit" with form, aria-*. |
| `action?` | `ReactNode` |  | Replaces the primary button entirely, for example a <Button asChild> link to the checkout. |
| `secondaryAction?` | `ReactNode` |  | An 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). |
| `variant?` | `"bar" \| "floating"` | `"bar"` | 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. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The primary button's color. Brand orange suits the one checkout action. |
| `color?` | `string` | `""` | Any CSS color for the primary button. Replaces the tone; the text turns black or white by lightness. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Button size 32 / 40 / 48px (44px hit areas on touch), bar content height 56 / 64 / 72px, price 16 / 17 / 19px. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | The buttons' corner shape, scaled with the size like the button's. |
| `loading?` | `boolean` | `false` | The primary button's spinner. It keeps its width and focus and ignores presses. |
| `disabled?` | `boolean` | `false` | Dims and blocks the primary action. |
| `showOn?` | `"always" \| "scroll" \| "anchor"` | `"always"` | 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. |
| `threshold?` | `number` | `200` | Scroll distance in px before the bar appears with showOn="scroll". |
| `anchorRef?` | `RefObject<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). |
| `hideOnScroll?` | `boolean` | `false` | Slides away with the finger while scrolling down and comes back when scrolling up and at the end of the page. |
| `keyboard?` | `"hide" \| "lift" \| "none"` | `"hide"` | 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. |
| `position?` | `"fixed" \| "sticky" \| "absolute"` | `"fixed"` | 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. |
| `hideFrom?` | `"none" \| "md" \| "lg"` | `"none"` | Hides the bar with CSS from this breakpoint up, where the page shows its own CTA column. |
| `safeArea?` | `boolean` | `true` | Pads 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. |
| `scrollTarget?` | `HTMLElement \| Window \| RefObject<HTMLElement \| null> \| null` |  | What scrolls, for showOn, hideOnScroll and the anchor. Default: the nearest ScrollContainerProvider, else the window. |
| `labels?` | `Partial<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. |
| `classNames?` | `Partial<Record<"inner" \| "summary" \| "price" \| "suffix" \| "compare" \| "badge" \| "caption" \| "actions" \| "secondary" \| "primary", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra 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 (constant)

The 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.

## See also

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) ([Markdown](https://wingo-ui.com/components/bottom-tab-bar.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))
- [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) ([Markdown](https://wingo-ui.com/components/use-keyboard-inset.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))

---

Canonical: https://wingo-ui.com/components/sticky-cta-bar
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
