Browse the library
Components / Navigation
Sticky contact bar
FreeMobile bottom bar with Call, Text, Directions and Book that hides on scroll down and returns on scroll up.
components/facade/sticky-contact-bar.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Calendar, MapPin, MessageCircle, Phone } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ContactAction {
key: string;
label: string;
href: string;
icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
/** Render as the filled, emphasized action. */
primary?: boolean;
/** Open in a new tab (e.g. maps, external booking). */
external?: boolean;
}
export interface StickyContactBarProps {
/** Phone number in any format, used for tel: and sms: links. */
phone?: string;
/** Street address for the directions link. */
address?: string;
/** Booking URL. */
bookingUrl?: string;
/** Override the default four actions. */
actions?: ContactAction[];
/** Pixels scrolled before the bar first appears. */
threshold?: number;
/** Pixels of continued downward scrolling before the bar tucks away. Scrolling up brings it back. */
hideAfter?: number;
/** Render absolute inside the nearest positioned parent instead of fixed to the viewport. */
contained?: boolean;
/** Element to watch for scroll. Defaults to window. Useful with `contained`. */
scrollContainerRef?: React.RefObject<HTMLElement | null>;
/** Hide on md screens and up. Ignored when `contained`. */
mobileOnly?: boolean;
className?: string;
}
function digits(phone: string) {
return phone.replace(/[^\d+]/g, "");
}
export function buildDefaultActions(phone?: string, address?: string, bookingUrl?: string): ContactAction[] {
const out: ContactAction[] = [];
if (phone) {
out.push({ key: "call", label: "Call", href: `tel:${digits(phone)}`, icon: Phone });
out.push({ key: "text", label: "Text", href: `sms:${digits(phone)}`, icon: MessageCircle });
}
if (address) {
out.push({
key: "directions",
label: "Directions",
href: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(address)}`,
icon: MapPin,
external: true,
});
}
if (bookingUrl) {
out.push({ key: "book", label: "Book", href: bookingUrl, icon: Calendar, primary: true });
}
return out;
}
export function StickyContactBar({
phone = "(512) 555-0142",
address = "1200 S Congress Ave, Austin, TX 78704",
bookingUrl = "#book",
actions,
threshold = 240,
hideAfter = 120,
contained = false,
scrollContainerRef,
mobileOnly = true,
className,
}: StickyContactBarProps) {
const reduce = useReducedMotion();
const [visible, setVisible] = React.useState(false);
const items = actions ?? buildDefaultActions(phone, address, bookingUrl);
React.useEffect(() => {
const target: HTMLElement | Window = scrollContainerRef?.current ?? window;
const read = () => (target instanceof Window ? target.scrollY : target.scrollTop);
let last = read();
// Distance scrolled down since the bar was last shown.
let downRun = 0;
let shown = false;
let frame = 0;
const set = (v: boolean) => {
if (v !== shown) {
shown = v;
setVisible(v);
}
};
const update = () => {
const y = read();
const delta = y - last;
last = y;
if (y < threshold) {
downRun = 0;
set(false);
return;
}
if (delta < -4) {
// Scrolling up: always bring the bar back.
downRun = 0;
set(true);
} else if (delta > 0) {
if (!shown && downRun === 0 && y - delta < threshold) {
// Just crossed the threshold: reveal once.
set(true);
} else {
downRun += delta;
if (downRun > hideAfter) set(false);
}
}
};
const onScroll = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(update);
};
// Already past the threshold on mount (e.g. restored scroll position): show right away.
if (last >= threshold) set(true);
update();
target.addEventListener("scroll", onScroll, { passive: true });
return () => {
cancelAnimationFrame(frame);
target.removeEventListener("scroll", onScroll);
};
}, [threshold, hideAfter, scrollContainerRef]);
return (
<AnimatePresence>
{visible && (
<motion.nav
aria-label="Quick contact"
initial={reduce ? { opacity: 0 } : { y: "110%" }}
animate={reduce ? { opacity: 1 } : { y: 0 }}
exit={reduce ? { opacity: 0 } : { y: "110%" }}
transition={{ type: "spring", stiffness: 420, damping: 38 }}
className={cn(
contained ? "absolute" : "fixed",
"inset-x-0 bottom-0 z-40 px-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] pt-2",
!contained && mobileOnly && "md:hidden",
className,
)}
>
<ul className="mx-auto flex max-w-md items-stretch gap-1 rounded-2xl border border-border bg-card/90 p-1.5 text-card-foreground shadow-lg shadow-foreground/10 backdrop-blur-xl">
{items.map((a) => {
const Icon = a.icon;
return (
<li key={a.key} className={cn(a.primary ? "flex-[1.4]" : "flex-1")}>
<a
href={a.href}
target={a.external ? "_blank" : undefined}
rel={a.external ? "noopener noreferrer" : undefined}
className={cn(
"flex h-12 w-full flex-col items-center justify-center gap-0.5 rounded-xl text-[11px] font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
a.primary
? "bg-brand text-brand-foreground hover:bg-brand/90"
: "text-foreground/80 hover:bg-muted hover:text-foreground active:bg-muted",
)}
>
<Icon className="size-[18px]" aria-hidden />
{a.label}
</a>
</li>
);
})}
</ul>
</motion.nav>
)}
</AnimatePresence>
);
}
export default StickyContactBar;app/page.tsx
"use client";
import * as React from "react";
import { ArrowRight, Clock, Scissors, Star } from "lucide-react";
import { StickyContactBar } from "@/components/facade/sticky-contact-bar";
const services = [
{ name: "Signature cut", price: "$48", time: "45 min" },
{ name: "Skin fade", price: "$42", time: "40 min" },
{ name: "Beard sculpt & hot towel", price: "$30", time: "30 min" },
{ name: "Cut + beard combo", price: "$70", time: "75 min" },
{ name: "Kids cut (under 12)", price: "$28", time: "30 min" },
{ name: "Gray blending", price: "$35", time: "30 min" },
];
export default function Demo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
return (
<div className="flex w-full items-center justify-center p-4">
{/* Phone frame */}
<div className="relative h-[400px] w-[230px] overflow-hidden rounded-[2.25rem] border-[6px] border-foreground/90 bg-background shadow-2xl shadow-foreground/20 sm:h-[400px] sm:w-[240px]">
<div className="absolute left-1/2 top-1.5 z-50 h-4 w-16 -translate-x-1/2 rounded-full bg-foreground/90" aria-hidden />
<div ref={scrollRef} className="h-full overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{/* Hero */}
<header className="relative bg-[radial-gradient(120%_80%_at_20%_0%,var(--accent)_0%,transparent_60%)] px-4 pb-6 pt-9">
<div className="flex items-center gap-2">
<span className="grid size-7 place-items-center rounded-lg bg-primary text-primary-foreground">
<Scissors className="size-3.5" aria-hidden />
</span>
<span className="text-xs font-semibold tracking-tight">Fifth & Main Barbers</span>
</div>
<h1 className="mt-5 font-display text-2xl font-semibold leading-tight tracking-tight">
Sharp cuts in East Nashville.
</h1>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
Walk-ins welcome. Most appointments open same day.
</p>
<div className="mt-3 flex items-center gap-1 text-[11px] text-muted-foreground">
<Star className="size-3 fill-warning text-warning" aria-hidden />
<span className="font-mono font-semibold text-foreground">4.9</span> ยท 860 Google reviews
</div>
<p className="mt-6 flex items-center gap-1 text-[11px] font-medium text-brand">
Scroll down <ArrowRight className="size-3 rotate-90" aria-hidden />
</p>
</header>
{/* Services */}
<section className="px-4 pb-28">
<h2 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Services</h2>
<ul className="divide-y divide-border rounded-xl border border-border bg-card">
{services.map((s) => (
<li key={s.name} className="flex items-center justify-between gap-2 px-3 py-3">
<span>
<span className="block text-xs font-medium">{s.name}</span>
<span className="mt-0.5 flex items-center gap-1 text-[10px] text-muted-foreground">
<Clock className="size-2.5" aria-hidden /> {s.time}
</span>
</span>
<span className="font-mono text-xs font-semibold">{s.price}</span>
</li>
))}
</ul>
<h2 className="mb-2 mt-6 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Visit us
</h2>
<div className="rounded-xl border border-border bg-card p-3 text-xs leading-relaxed text-muted-foreground">
1008 Woodland St, Nashville, TN 37206
<br />
Tue to Sat, 9 AM to 7 PM
</div>
<div className="mt-4 h-24 rounded-xl bg-[repeating-linear-gradient(135deg,var(--muted)_0_10px,transparent_10px_20px)]" />
</section>
</div>
<StickyContactBar
contained
scrollContainerRef={scrollRef}
threshold={120}
hideAfter={80}
phone="(615) 555-0187"
address="1008 Woodland St, Nashville, TN 37206"
bookingUrl="#book"
className="px-2 pb-2"
/>
</div>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/sticky-contact-bar.jsonUsage
import { StickyContactBar } from "@/components/facade/sticky-contact-bar";
export default function Page() {
return <StickyContactBar />;
}Props
StickyContactBarProps is exported, so you can extend or wrap it. Every prop has a default; <StickyContactBar /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| phone | string | Phone number in any format, used for tel: and sms: links. |
| address | string | Street address for the directions link. |
| bookingUrl | string | Booking URL. |
| actions | ContactAction[] | Override the default four actions. |
| threshold | number | Pixels scrolled before the bar first appears. |
| hideAfter | number | Pixels of continued downward scrolling before the bar tucks away. Scrolling up brings it back. |
| contained | boolean | Render absolute inside the nearest positioned parent instead of fixed to the viewport. |
| scrollContainerRef | React.RefObject<HTMLElement | null> | Element to watch for scroll. Defaults to window. Useful with `contained`. |
| mobileOnly | boolean | Hide on md screens and up. Ignored when `contained`. |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade sticky contact bar to this page and adapt the copy to our business.