Blog//7 min read
Sticky mobile contact bar: Call, Text, Directions, Book
Build a sticky call button for a mobile website in React: tel and sms links, hide on scroll down, safe area insets, md:hidden and tap tracking.
MSMarco Schulz, founder of Facade UI
On a phone, people scroll past the hero and never find the phone number. It sits in the header, the header scrolled away three screens ago, and the visitor is now reading reviews with no way to act on them. The fix is a bar that follows them and gets out of the way when they are reading. This is how I build the Sticky contact bar, and the small decisions that make it feel like part of the phone instead of a popup.
Four actions, no more
The bar has room for four actions. For a local business they are almost always the same four:
- Call: a
tel:link. On a phone it opens the dialer with the number filled in. - Text: an
sms:link. Younger customers prefer it, and a barber or a salon can answer between clients. - Directions: a Google Maps directions URL with the street address as destination. It opens the Maps app on both iOS and Android.
- Book: the booking page. This is the primary action and gets the brand color and a little more width than the others.
The component builds these four from three props: phone, address and bookingUrl. It strips the phone number down to digits and a leading plus for the tel: and sms: hrefs, so you can pass "(512) 555-0199" and get tel:5125550199. Directions become https://www.google.com/maps/dir/?api=1&destination=<encoded address>, which is the documented Maps URL format and does not need an API key.
import { StickyContactBar } from "@/components/facade/sticky-contact-bar";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<StickyContactBar
phone="(512) 555-0199"
address="1200 S Congress Ave, Austin, TX 78704"
bookingUrl="/book"
threshold={240}
hideAfter={120}
mobileOnly
/>
</>
);
}If the four defaults do not fit, pass actions with your own ContactAction list. A dentist might swap Text for an insurance check link; a restaurant swaps Book for a reservation link and Text for the menu. Each action has a key, label, href, an icon component from lucide-react, an optional primary flag and an external flag for links that should open in a new tab.
Hide on scroll down, show on scroll up
A bar that is always visible eats 70 pixels of a small screen. A bar that is never visible is useless. The behavior that works is the one mobile browsers use for their own toolbars: hide while the visitor scrolls down to read, bring it back the moment they scroll up.
The implementation tracks the scroll position with a passive scroll listener and keeps two numbers: the last position and the length of the current downward run. The rules:
- Above
thresholdpixels from the top, the bar is hidden. The hero usually has its own call to action, and stacking a bar on top of it looks cluttered. - The first time the visitor crosses the threshold, the bar reveals. This is the moment they have shown intent to read on.
- Every pixel of continued downward scrolling adds to the run. Once the run passes
hideAfter, the bar tucks away. - Any upward scroll resets the run and brings the bar back.
Two defaults I arrived at after a few client sites: threshold at 240 pixels and hideAfter at 120. A smaller hideAfter makes the bar flicker when a thumb wobbles. A larger one keeps the bar in the way for half a screen. The component also checks the position on mount, so a visitor who lands with a restored scroll position, for example after pressing back, sees the bar immediately instead of waiting for a scroll event.
The show and hide is a short slide with AnimatePresence from motion, and it respects prefers-reduced-motion by fading instead of sliding.
Safe area insets
On phones with a home indicator, a bar pinned to bottom: 0 sits under the indicator and the bottom few pixels become unreachable. The fix is one CSS value: pad the bottom with env(safe-area-inset-bottom). In Tailwind that is an arbitrary value, pb-[calc(env(safe-area-inset-bottom)+0.75rem)], which adds the device's inset to the normal padding. On phones without an inset the value is zero and nothing changes.
For the inset to work at all, the viewport meta tag has to include viewport-fit=cover. Next.js sets width=device-width, initial-scale=1 by default; add viewportFit: "cover" to the exported viewport object in your root layout.
The second safe area problem is the page content. If the bar covers the last 70 pixels of the page, the footer's last line is unreadable. Add bottom padding to the page container on mobile equal to the bar height, or put the bar in the layout and give <main> a pb-24 md:pb-0.
Only on mobile
On a desktop the phone number in the header is one click away and tel: links open an awkward app chooser. The bar should not exist there. The component takes mobileOnly and applies md:hidden, which means the bar renders in the DOM but is hidden from the md breakpoint up. The DOM presence matters for one reason: the markup is the same on every screen, so there is no hydration mismatch between server and client.
The bar is a landmark labelled "Quick contact" that wraps a list of links, so a screen reader user on a phone finds it by landmark. Icons are aria-hidden and every link has a text label, which also keeps the tap targets wide enough to hit.
There is also a contained mode that renders the bar absolutely inside the nearest positioned parent instead of fixed to the viewport. I use it for component previews and for in-page demos; on a real site you want the fixed version.
Tracking taps
A bar that gets taps is a bar you can justify to the client. Calls are the one conversion a plumber or a barber cares about, and a tel: tap is as close as the web gets to measuring one. The approach:
- Attach a click handler at the bar level and read the
hrefof the tapped link to work out which action it was. - Send an event to your analytics with the action key as the name:
contact_call,contact_text,contact_directions,contact_book. - Do not
preventDefault. The event fires, the dialer opens, both happen.
With Vercel Analytics it is a track("contact_call") call. With GA4 it is gtag("event", "contact_call"). If you pass your own actions, add onClick to each one; the default actions render as plain anchors, so a delegated listener on the wrapper is the cleanest option.
After a month you will know which action matters for that business, and the client gets a number instead of a feeling. The barber page shows where the bar fits in a full page order with the open now badge and the location card.
Installing
The Sticky contact bar is free and MIT licensed:
npx shadcn@latest add https://facadeui.com/r/sticky-contact-bar.jsonIt depends on motion and lucide-react, which shadcn installs with it. Render it once in the root layout so it persists across page navigations, and pass the same phone and address you use in the footer so the numbers never disagree.
FAQ
Does the sms link work on every phone?
sms: with a number works on iOS and Android. Some desktop browsers show an error or do nothing, which is one more reason to hide the bar above the md breakpoint. Do not add a prefilled body parameter; support for it is inconsistent across platforms.
What about a WhatsApp action?
Pass a custom action with the https://wa.me/<number> URL and the external flag. Many clients outside the US prefer it over SMS, and the bar does not care what the link points to.
Will the fixed bar hurt my Core Web Vitals?
Not if it is rendered on the server and does not shift content. Cumulative Layout Shift measures unexpected movement of visible content; a fixed bar that slides in from off screen does not count. Keep the bottom padding on <main> in the initial CSS so nothing jumps when it appears.