Blog · · 7 min

Sticky add to cart on mobile

The sticky add to cart bar appears only once the inline button has scrolled out of view, never beside it, and it carries the selected total rather than a generic label. Pad with env(safe-area-inset-bottom), keep the button out of the tab order while hidden, hide it above 768px.

By

Three rules decide whether a sticky add-to-cart helps or annoys. It appears only after the inline button has left the viewport, so the two are never on screen at once competing for the same tap. It carries the selected total, Add 3 tins · $48.00, so one glance replaces scrolling back up to check what was chosen. It respects the bottom safe area and the page's own bottom padding, so it never sits on top of the last line of content or under the home indicator.

Everything after this is the implementation of those three.

Why it exists at all

On a 390px viewport the inline add-to-cart button is out of view for most of the session, because the buyer scrolled down to read the description, the ingredients, the shipping terms, the returns policy. The decision to buy is usually made somewhere down there, and at that moment the button is a scroll away in a direction the buyer has to guess.

That is the entire mechanic. It is not persuasion, it is reachability, which is why it is the one component in the set with no legal caveat attached.

Detect with IntersectionObserver, never a scroll listener

A scroll handler runs on every frame of every scroll, on exactly the device this component exists for, and it is a known cause of jank. Observe the inline button instead and show the bar when it stops intersecting.

useEffect(() => {
  const cible = ancre.current
  if (!cible) return

  const io = new IntersectionObserver(
    ([entree]) => setVisible(!entree.isIntersecting),
    { rootMargin: "0px 0px -8px 0px" },
  )
  io.observe(cible)
  return () => io.disconnect()
}, [ancre])

The ancre prop is a ref to the inline button, not to a section or an arbitrary sentinel. Anchoring to a section makes the bar appear while the button is still half visible, which produces the duplicate-button state the rule exists to prevent. The negative rootMargin fires the transition a few pixels early so the handoff has no gap.

What the bar must carry

SlotContentFailure it prevents
Labelthe selected option, Add 3 tinsa generic Add to cart that hides which tier is active
Amountthe selected total, in currency, tabular figuresscrolling back up to verify the price
Statedisabled when the product cannot be addeda tap that silently does nothing
Heightat least 44px of tap target, 52px is comfortablemis-taps on a thumb-reach control

The amount is the half people skip. A sticky bar reading Add to cart on a product page with three tiers tells the buyer nothing about what they are about to be charged, and the tier selector is usually far above by then. Bind it to the selection, the same way the inline button is bound to it in the bundle quantity selector.

Use font-variant-numeric: tabular-nums on the figure. It changes when the tier changes, and proportional digits make it shift sideways.

The four mistakes

Focusable while hidden. A bar translated off screen with a live <button> inside it is a keyboard trap: the focus ring lands on a control nobody can see. Set tabIndex={visible ? 0 : -1} and aria-hidden={!visible}. This is the defect that never shows up in manual testing, because nobody tabs through a phone.

Covering the last content. position: fixed removes the bar from the flow, so the final rows of the page sit underneath it. Reserve the space at the bottom of the page, not only inside the bar.

Ignoring the safe area. On an iPhone the home indicator sits in the bottom inset. A bar padded with a flat 10px puts its button partly under it.

.sac{
  position:fixed;left:0;right:0;bottom:0;z-index:40;
  padding:10px 14px calc(10px + env(safe-area-inset-bottom,0px));
  transform:translateY(110%);pointer-events:none;
}
.sac.is-visible{transform:translateY(0);pointer-events:auto}

/* Above 768px the inline button is still reachable, so the bar is dead weight
   and one more thing covering the page. */
@media (min-width:768px){.sac{display:none}}

@media (prefers-reduced-motion:no-preference){
  .sac{transition:transform .22s cubic-bezier(.22,.61,.36,1)}
}

Note pointer-events:none in the hidden state. Without it the invisible bar still swallows taps in the bottom strip of the screen, which is where cookie banners, chat widgets and the browser's own toolbar already fight for room.

Animating a property that relaunches layout. Move it with transform, never with bottom or height. A layout-triggering property written on every frame is the classic source of a bar that stutters as it arrives, and it costs nothing to avoid.

Does it stack with the free-shipping bar?

Not in the same place. Two fixed elements at the bottom of a 390px screen leaves the buyer with a viewport and two floating strips. Pick one owner for that region.

The practical arrangement: the sticky button owns the bottom of the product page, and the free-shipping threshold bar lives inside the cart, in the flow, above the totals. They act at different moments and never need to be visible together. If your cart is a drawer over the product page, hide the sticky bar while the drawer is open.

The same goes for the order bump: it is a line in the cart, not a floating element, and turning it into a bottom sheet on mobile converts an unobtrusive line into an interruption.

Where it sits among the other three

It is fourth of four, and last for a reason that is not importance: it acts on every mobile session, but it changes reachability rather than the composition of the order. Quantity breaks change what goes in the basket, and they are the first thing to build. The sticky bar then carries their result down the page.

Built in that order, the bar has something worth saying. Built first, it says Add to cart and it is a shortcut to a decision the page has not helped anyone make.

Why an agent gets this wrong

Ask a coding agent for a sticky add-to-cart and you generally get a position: fixed div rendered unconditionally, with a generic label, no safe-area padding, and a focusable button behind a display: none that is toggled on a scroll offset. Every one of those is the most common snippet on the open web rather than a judgement about this component.

None of the general React or Tailwind component registries we surveyed carries it either; the inventory is in shadcn ecommerce blocks: what exists and what's missing. The full implementation, MIT, is in the commerce kit.

FAQ

When should the sticky add to cart appear?

Only once the inline add-to-cart button has scrolled out of the viewport. Observe that button with an IntersectionObserver and show the bar when it stops intersecting. Two visible add-to-cart controls at the same moment is the failure this timing exists to prevent.

What should the sticky bar say?

The selected option and its total, in currency: Add 3 tins · $48.00. A generic Add to cart forces the buyer back up the page to check which tier is active and what it costs, which is the scroll the bar was supposed to remove.

Should the sticky add to cart show on desktop?

No. Above 768px the inline button is still reachable and the bar is dead weight covering the page. Hide it with a media query rather than trying to reposition it.

How do I stop it covering the bottom of the page?

Pad the page itself by the bar's height plus env(safe-area-inset-bottom). A fixed element is out of the flow, so nothing below it reserves space, and the last rows of content end up underneath. The same inset keeps the button clear of the iPhone home indicator.