Newsletter Capture Band
Email capture with a four-state submission machine and an aria-live status line, so the outcome is announced and not just shown.
The call to action that follows a long page down — held back until the reader is past a scroll threshold, dismissible, and spaced so it never covers the content it is selling.
components/cta-sticky-bar.tsxnpx hoverlab add cta-sticky-bar
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Page content sits here. The bar rides above it once the reader is past the threshold, and the spacer at the end of the document keeps the last element reachable.
Start with the free tier
Every component readable and installable. A licence only when you ship it commercially.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <CtaStickyBar> — the conversion bar that follows the page down.
*
* CTA Sections had three blocks that all sit in the flow: a newsletter
* band, a community band, a closing panel. Every one of them is only on
* screen while the reader happens to be scrolled to it, which means a long
* landing page has its call to action visible for perhaps a tenth of the
* time somebody spends reading. This is the shape that fixes that, and it
* is the one most likely to be built badly.
*
* THE THREE WAYS THIS PATTERN GOES WRONG
*
* It appears immediately. A sticky bar on first paint is an interstitial
* with extra steps — it interrupts before the page has made any case at
* all. This one waits until the reader is past a threshold, because
* somebody who has scrolled a third of a page has demonstrated interest
* and somebody who just arrived has not.
*
* It cannot be dismissed. A bar that eats 64px of a phone screen with no
* close button is a tax on everyone who has already decided no.
* Dismissal is a real button, and it stays dismissed.
*
* It covers the thing it is selling. On a page ending in a form or a
* footer link, the bar sits on top of the content. The fix is not
* z-index — it is reserving the bar's height at the end of the document,
* which is what the spacer below does, so anchored jumps and keyboard
* focus land above the bar rather than underneath it.
*
* POSITIONING. The bar is `absolute` inside a `relative` frame, the same
* way <CookieConsent> is written and for the same reason: a `fixed`
* element ignores every container it is in, so in a preview grid it would
* escape its card and pin itself to the browser window. In a real page you
* drop the frame and swap `absolute` for `fixed` — the classes are
* otherwise identical.
*
* WHY IT ANIMATES IN, AND WHY THAT IS GUARDED
*
* A bar that appears instantly at the moment of a scroll reads as a layout
* jump — the eye interprets it as the page breaking rather than as an
* offer. A 200ms rise makes it legible as an arrival. It is a finite
* transition rather than a loop, and `motion-reduce:transition-none` turns
* it into a plain appearance for anyone who asked for less motion.
*
* ACCESSIBILITY
*
* A `<section>` with an accessible name, not an `aria-live` region. It is
* not an alert, and announcing a marketing offer over whatever someone is
* reading is the audio equivalent of a pop-up. It comes last in the DOM so
* tabbing reaches it at the end rather than interrupting a form halfway
* down the page.
*/
import * as React from 'react'
import { ArrowRight, X } from 'lucide-react'
export interface CtaStickyBarProps {
headline?: string
sub?: string
actionLabel?: string
actionHref?: string
secondaryLabel?: string
secondaryHref?: string
/** Fraction of the page scrolled before it appears. 0 shows immediately. */
showAfter?: number
/**
* Render as a demo inside a larger page.
*
* Skips the scroll threshold and shows the bar straight away. A preview
* card cannot demonstrate a page-scroll behaviour — the host page's
* scroll position has nothing to do with this component's frame — so
* without this the block previews as an empty rectangle, which is the
* worst possible advertisement for a bar whose entire job is to be seen.
*
* It does not change the default: a real page keeps `showAfter` and gets
* the delayed appearance the comment above argues for.
*/
embedded?: boolean
className?: string
}
export function CtaStickyBar({
headline = 'Start with the free tier',
sub = 'Every component readable and installable. A licence only when you ship it commercially.',
actionLabel = 'Get started',
actionHref = '#',
secondaryLabel = 'See pricing',
secondaryHref = '#',
showAfter = 0.35,
embedded = false,
className = '',
}: CtaStickyBarProps) {
const [visible, setVisible] = React.useState(embedded || showAfter <= 0)
const [dismissed, setDismissed] = React.useState(false)
React.useEffect(() => {
if (embedded || showAfter <= 0) return
function onScroll() {
const scrollable = document.documentElement.scrollHeight - window.innerHeight
/*
A page shorter than the viewport has nothing to scroll, so the
threshold could never be met and the bar would never appear.
Treating that as "already past it" is right: a page with no scroll
is one the reader has seen all of.
*/
const progress = scrollable > 0 ? window.scrollY / scrollable : 1
setVisible(progress >= showAfter)
}
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [showAfter, embedded])
return (
// The demo frame. Delete it in a real page — see POSITIONING above.
<div className="relative min-h-[13rem] overflow-hidden rounded-xl border border-border bg-muted/20">
<div className="px-6 py-8 text-sm text-muted-foreground">
<p className="max-w-prose">
Page content sits here. The bar rides above it once the reader is
past the threshold, and the spacer at the end of the document keeps
the last element reachable.
</p>
</div>
{/* The spacer, not a z-index fight. */}
<div aria-hidden className="h-20 sm:h-16" />
{dismissed ? null : (
<section
aria-label="Get started"
className={`absolute inset-x-0 bottom-0 z-10 border-t border-border bg-card/95 backdrop-blur transition duration-200 motion-reduce:transition-none ${
visible
? 'translate-y-0 opacity-100'
: 'pointer-events-none translate-y-full opacity-0'
} ${className}`}
>
<div className="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 py-3 sm:px-6">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">{headline}</p>
{/* Hidden on the smallest screens rather than wrapped to three
lines — a sticky bar that grows taller than its own value
is the thing people are trying to close. */}
<p className="hidden truncate text-sm text-muted-foreground sm:block">{sub}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<a
href={secondaryHref}
className="hidden h-9 items-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:inline-flex"
>
{secondaryLabel}
</a>
<a
href={actionHref}
className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{actionLabel}
<ArrowRight aria-hidden className="h-4 w-4 rtl:rotate-180" />
</a>
<button
type="button"
onClick={() => setDismissed(true)}
className="-me-1 rounded p-1.5 text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<X aria-hidden className="h-4 w-4" />
<span className="sr-only">Dismiss this bar</span>
</button>
</div>
</div>
</section>
)}
</div>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/cta-sticky-bar.tsx and import it where you need the section:
import { CtaStickyBar } from '@/components/cta-sticky-bar'7 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
headline | string | 'Start with the free tier' |
sub | string | — |
actionLabel | string | 'Get started' |
actionHref | string | '#' |
secondaryLabel | string | 'See pricing' |
secondaryHref | string | '#' |
showAfterFraction of the page scrolled before it appears. 0 shows immediately. | number | 0.35 |
embeddedRender as a demo inside a larger page. Skips the scroll threshold and shows the bar straight away. A preview card cannot demonstrate a page-scroll behaviour — the host page's scroll position has nothing to do with this component's frame — so without this the block previews as an empty rectangle, which is the worst possible advertisement for a bar whose entire job is to be seen. It does not change the default: a real page keeps `showAfter` and gets the delayed appearance the comment above argues for. | boolean | false |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Sticky Conversion Bar — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<div class="relative min-h-[13rem] overflow-hidden rounded-xl border border-border bg-muted/20">
<div class="px-6 py-8 text-sm text-muted-foreground">
<p class="max-w-prose">Page content sits here. The bar rides above it once the reader is past the threshold, and the spacer at the end of the document keeps the last element reachable.</p>
</div>
<div aria-hidden="true" class="h-20 sm:h-16"></div>
<section aria-label="Get started" class="absolute inset-x-0 bottom-0 z-10 border-t border-border bg-card/95 backdrop-blur transition duration-200 motion-reduce:transition-none pointer-events-none translate-y-full opacity-0 ">
<div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 py-3 sm:px-6">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">Start with the free tier</p>
<p class="hidden truncate text-sm text-muted-foreground sm:block">Every component readable and installable. A licence only when you ship it commercially.</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<a href="#" class="hidden h-9 items-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:inline-flex">See pricing</a>
<a href="#" class="inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Get started<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right h-4 w-4 rtl:rotate-180" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg></a>
<button type="button" class="-me-1 rounded p-1.5 text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-4 w-4" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
<span class="sr-only">Dismiss this bar</span>
</button>
</div>
</div>
</section>
</div>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse CTA SectionsCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
Email capture with a four-state submission machine and an aria-live status line, so the outcome is announced and not just shown.
A closing CTA of three link cards pointing at GitHub, chat and social — somewhere to go for the visitor who is not ready to convert.
A rounded gradient panel that restates the offer for the reader who scrolled past everything — heading left, both CTAs right, reassurance points underneath.
The offer that sits inside the column instead of interrupting it — a labelled aside a screen reader can skip in one keystroke, tied to the paragraph above it, with the friction admitted before the click.
A waitlist capture that shows position and referral credit, turning a dead-end confirmation into the one action that moves the queue.
A demo request that offers the self-serve escape first, so the people who did not want a call do not book one.