Contextual CTA
atroui/contextual-ctaFreeBlock
Sticky scroll CTA with editable CONTENT (no analytics wiring).
Install it
npx shadcn@latest add https://atroui.com/r/contextual-cta.jsonSource
1"use client"23import { ArrowRight, X } from "lucide-react"4import Link from "next/link"5import { usePathname } from "next/navigation"6import { useEffect, useState } from "react"78/**9 * Edit CONTENT to change the sticky scroll CTA copy and thresholds.10 * Simplified vs the docs package demo (no analytics experiment wiring).11 */12const CONTENT = {13 stamp: "Free intro",14 headline: "Ready to ship?",15 body: "Book a 15-min intro - no pitch deck.",16 cta: "Book a call",17 href: "/contact#book",18 hideOn: ["/contact"],19 showAtPct: 28,20 hideAtPct: 94,21 dismissKey: "atroui_cta_dismissed",22}2324export type ContextualCtaProps = {25 /** Docs / demos: skip scroll gating and render inline. */26 preview?: boolean27}2829export function ContextualCta({ preview = false }: ContextualCtaProps) {30 const pathname = usePathname()31 const [visible, setVisible] = useState(preview)32 const [dismissed, setDismissed] = useState(false)33 const [ready, setReady] = useState(preview)3435 useEffect(() => {36 if (preview) return37 try {38 if (sessionStorage.getItem(CONTENT.dismissKey) === "1") {39 setDismissed(true)40 }41 } catch {42 /* ignore */43 }44 setReady(true)45 }, [preview])4647 useEffect(() => {48 if (preview || !ready || dismissed) return4950 const onScroll = () => {51 const denom =52 document.documentElement.scrollHeight - window.innerHeight53 if (denom <= 0) {54 setVisible(false)55 return56 }57 const pct = (window.scrollY / denom) * 10058 setVisible(pct >= CONTENT.showAtPct && pct < CONTENT.hideAtPct)59 }6061 onScroll()62 window.addEventListener("scroll", onScroll, { passive: true })63 return () => window.removeEventListener("scroll", onScroll)64 }, [dismissed, ready, pathname, preview])6566 const onHiddenPath =67 !preview &&68 CONTENT.hideOn.some(69 (p) => pathname === p || pathname.startsWith(`${p}/`),70 )7172 if (!ready || dismissed || !visible || onHiddenPath) return null7374 const dismiss = () => {75 setDismissed(true)76 if (preview) return77 try {78 sessionStorage.setItem(CONTENT.dismissKey, "1")79 } catch {80 /* ignore */81 }82 }8384 return (85 <div86 className={87 preview88 ? "relative w-full pb-3"89 : "fixed inset-x-0 bottom-0 z-40 pb-[max(0.75rem,env(safe-area-inset-bottom))]"90 }91 role="complementary"92 aria-label="Suggested action"93 >94// … truncated
What it pulls in
npm packages
Files it writes
More from atroui
All 82 items| Component | Registry | Kind | Access | Installs | Command |
|---|---|---|---|---|---|
| Analytics Provideranalytics-provider | atroui | Blocks | Free | 1 dep | |
| AR Portfolioar-portfolio | atroui | Blocks | Free | 1 dep | |
| Before / Afterbefore-after-slider | atroui | Blocks | Free | no deps | |
| Calendly Embedcalendly-embed | atroui | Blocks | Free | 1 dep | |
| Changelogchangelog | atroui | Blocks | Free | no deps | |
| Command Menucommand-menu | atroui | Blocks | Free | 4 deps | |
| Contact Formcontact-form | atroui | Blocks | Free | 2 deps | |
| Count Upcount-up | atroui | Blocks | Free | no deps |
