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 uxgen
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
| Slot | Content | Failure it prevents |
|---|---|---|
| Label | the selected option, Add 3 tins | a generic Add to cart that hides which tier is active |
| Amount | the selected total, in currency, tabular figures | scrolling back up to verify the price |
| State | disabled when the product cannot be added | a tap that silently does nothing |
| Height | at least 44px of tap target, 52px is comfortable | mis-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.