Call to action
One ask, set apart from the page around it: start a trial, book a demo, join a list.
Centred call to action
call-to-action-01One sentence set large, one line that backs it, the two ways in and what the first one costs, centred with room around it.
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { HEADLINE, LEDE, PRIMARY_ACTION, SECONDARY_ACTION, TERMS } from "./call-to-action-01.data"
/**
* The ask on its own: one sentence set large, one line that backs it, the two
* ways in and what the first one costs, centred in a narrow column with room
* around it. Nothing competes with the headline — this is the band a page
* ends on when everything above it has already made the case.
*
* A server component with no state; every word is in
* `./call-to-action-01.data`.
*/
export function CallToAction01() {
const titleId = React.useId()
return (
<section
data-section="call-to-action-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
<h2 id={titleId} className="type-display text-3xl text-balance md:text-5xl">
{HEADLINE}
</h2>
<p className="mt-5 max-w-2xl text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link href={PRIMARY_ACTION.href} className={cn(buttonVariants({ size: "lg" }), "h-10 px-5")}>
{PRIMARY_ACTION.label}
</Link>
<Link
href={SECONDARY_ACTION.href}
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "h-10 px-5")}
>
{SECONDARY_ACTION.label}
</Link>
</div>
<p className="mt-5 text-sm text-balance text-muted-foreground tabular-nums">{TERMS}</p>
</div>
</div>
</section>
)
}
export default CallToAction01/**
* Every word `call-to-action-01` sets: the ask, the line that backs it, the
* two ways in and what the first one costs. The links name routes of your own
* site, so replacing this file is the whole edit.
*/
export type CtaLink = { label: string; href: string }
export const HEADLINE = "Start next Monday's review with the numbers already in"
export const LEDE =
"Connect billing, your CRM and the support desk in about twenty minutes. Northwind builds the first dashboards from what it finds, and your team edits them from there."
export const PRIMARY_ACTION: CtaLink = { label: "Start free trial", href: "/sign-up" }
export const SECONDARY_ACTION: CtaLink = { label: "Book a demo", href: "/contact" }
/** What pressing the first button commits the reader to, in one line under both. */
export const TERMS = "Free for 14 days, then $24 per seat a month. No card to start."npx shadcn@latest add @vibra/call-to-action-01Call to action beside a cohort report
call-to-action-02The ask and two ways in beside the product itself: a framed card holding a retention heatmap of eight signup weeks, with the week that started slowest named under it.
import * as React from "react"
import Link from "next/link"
import { TrendingDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatPercent } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { CohortGrid } from "./call-to-action-02.client"
import {
COHORTS,
EYEBROW,
HEADLINE,
LEDE,
PRIMARY_ACTION,
REPORT,
SECONDARY_ACTION,
slowestStart,
} from "./call-to-action-02.data"
/** A share as a whole percentage, the way the grid prints it: "63%". */
function percent(value: number): string {
return formatPercent(value, { maximumFractionDigits: 0 })
}
/**
* The ask beside the thing it asks the reader to try: the claim, the lede and
* two ways in on one side, and on the other a cohort report as the product
* draws it — the kit's framed card holding a HeatmapGrid of eight signup weeks,
* with the week that started slowest named in the card's footer. Built from
* kit parts rather than a screenshot, so it is sharp at every width, reads
* out to a screen reader and takes the reader's palette and mode.
*
* The grid is the one island; this file is a server component, and every
* word and figure is in `./call-to-action-02.data`. The note is filled from
* the weeks by `slowestStart`, so it cannot disagree with the grid.
*/
export function CallToAction02() {
const titleId = React.useId()
const slowest = slowestStart(COHORTS)
const note = REPORT.note
.replace("{week}", slowest.week)
.replace("{value}", percent(slowest.value))
.replace("{low}", percent(slowest.low))
.replace("{high}", percent(slowest.high))
return (
<section
data-section="call-to-action-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl items-center gap-12 lg:grid-cols-12 lg:gap-16">
<div className="lg:col-span-5">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link href={PRIMARY_ACTION.href} className={cn(buttonVariants({ size: "lg" }), "h-10 px-5")}>
{PRIMARY_ACTION.label}
</Link>
<Link
href={SECONDARY_ACTION.href}
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "h-10 px-5")}
>
{SECONDARY_ACTION.label}
</Link>
</div>
</div>
<Card className="lg:col-span-7">
<CardHeader>
<CardTitle>{REPORT.title}</CardTitle>
<CardDescription>{REPORT.description}</CardDescription>
<CardAction>
<Badge variant="outline">{REPORT.badge}</Badge>
</CardAction>
</CardHeader>
<CardContent className="flex justify-center px-2 py-6 sm:px-(--card-spacing)">
<CohortGrid rows={COHORTS} columns={REPORT.columns} label={REPORT.label} />
</CardContent>
<CardFooter className="items-start text-sm text-muted-foreground">
<TrendingDownIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-warning" />
<p className="text-pretty tabular-nums">{note}</p>
</CardFooter>
</Card>
</div>
</section>
)
}
export default CallToAction02"use client"
import { formatPercent } from "@/lib/format"
import { HeatmapGrid } from "@/components/ui/heatmap-grid"
import type { CohortRow } from "./call-to-action-02.data"
/** A share as a whole percentage, the way a cohort grid prints it: "67%". */
function percent(value: number): string {
return formatPercent(value, { maximumFractionDigits: 0 })
}
/**
* The report's grid: one row per signup week, one column per week after it,
* each cell the share still active on the absolute scale from none to all —
* the kit's HeatmapGrid, which reads every cell out by name and draws its
* own key. A week a cohort has not reached yet is a gap, not a zero.
*
* Its cells are fluid: the eight weeks share the card's width, square, from
* about 36px on a phone up to the 57px the grid's own width caps them at on a
* desk, so one grid holds every week at every width. An island only because
* the grid takes its formatter as a function; the weeks arrive as plain props.
*/
export function CohortGrid({ rows, columns, label }: { rows: CohortRow[]; columns: string[]; label: string }) {
return (
<HeatmapGrid
rows={rows.map((row) => row.week)}
columns={columns}
values={rows.map((row) => columns.map((_, week) => row.retention[week] ?? Number.NaN))}
min={0}
max={1}
color="chart-2"
valueFormatter={percent}
showValues
fluid
cellSize={32}
aria-label={label.replace("{weeks}", String(columns.length))}
className="max-w-lg"
/>
)
}/**
* Every word and figure `call-to-action-02` sets: the ask beside a cohort
* report from the product, and the weeks the report draws.
*
* The weeks are the sample store's own — `db.cohorts` as of the kit's
* `REFERENCE_DATE`, the eight signup weeks that have a week behind them — so
* the card shows what a Northwind workspace shows. They are written out
* here because a marketing section is copy and reads no store; the section's
* test holds every figure to `db.cohorts`, so the two cannot drift apart.
* Replace them with your own product's numbers and the card draws those.
*/
export type CtaLink = { label: string; href: string }
/** One signup week: its Monday, how many accounts opened, and the share still active each week after. */
export type CohortRow = { week: string; accounts: number; retention: number[] }
export const EYEBROW = "Cohort reports"
export const HEADLINE = "See which signup weeks stay, and which start to slip"
export const LEDE =
"Northwind follows every signup week, week by week, so a cohort that falls behind shows up while you can still call the accounts in it."
export const PRIMARY_ACTION: CtaLink = { label: "Start free trial", href: "/sign-up" }
export const SECONDARY_ACTION: CtaLink = { label: "See a cohort report", href: "/product/cohorts" }
export const REPORT = {
title: "Retention by signup week",
description: "Share of each week's new accounts still active",
badge: "Sample data",
/** What the grid is to assistive technology, in one sentence; `{weeks}` is the last week it shows. */
label: "Share of each signup week's accounts still active, weeks 1 to {weeks}",
columns: ["Week 1", "Week 2", "Week 3", "Week 4", "Week 5", "Week 6", "Week 7", "Week 8"],
/** The note under the grid; `{week}`, `{value}`, `{low}` and `{high}` are filled from the weeks by `slowestStart`. */
note: "The {week} week kept {value} of its accounts after a week, against {low} to {high} for the others.",
}
export const COHORTS: CohortRow[] = [
{ week: "Jul 6", accounts: 165, retention: [0.663, 0.4646, 0.3333, 0.2558, 0.204, 0.171, 0.1539, 0.1425] },
{ week: "Jul 13", accounts: 277, retention: [0.653, 0.4701, 0.3432, 0.2584, 0.206, 0.1801, 0.1566] },
{ week: "Jul 20", accounts: 458, retention: [0.671, 0.4668, 0.3511, 0.2715, 0.2232, 0.1897] },
{ week: "Jul 27", accounts: 393, retention: [0.6678, 0.4636, 0.3437, 0.2616, 0.2135] },
{ week: "Aug 3", accounts: 224, retention: [0.6724, 0.4784, 0.3435, 0.2603] },
{ week: "Aug 10", accounts: 435, retention: [0.6265, 0.4277, 0.3168] },
{ week: "Aug 17", accounts: 216, retention: [0.6574, 0.4693] },
{ week: "Aug 24", accounts: 419, retention: [0.6534] },
]
/**
* The week that kept the fewest of its accounts after one week, and the range
* the other weeks kept — counted from the rows, so the note under the grid can
* never name a figure the grid does not show.
*/
export function slowestStart(rows: CohortRow[]): { week: string; value: number; low: number; high: number } {
const ranked = [...rows].sort((a, b) => a.retention[0] - b.retention[0])
const [slowest, ...others] = ranked
const kept = others.map((row) => row.retention[0])
return { week: slowest.week, value: slowest.retention[0], low: Math.min(...kept), high: Math.max(...kept) }
}npx shadcn@latest add @vibra/call-to-action-02Call to action with a trial sign-up
call-to-action-03A panel with the ask on one side and, on the other, the one field a trial needs: it refuses a bad address in its own words and confirms a good one, naming where the link went.
import * as React from "react"
import Link from "next/link"
import { TrialSignup } from "./call-to-action-03.client"
import { FORM, HEADLINE, LEDE, TERMS } from "./call-to-action-03.data"
const TERMS_LINK = "rounded-sm text-foreground underline underline-offset-4 focus-ring hover:text-brand"
/**
* The ask that takes its answer in place: a panel on the surface plane with
* the claim and what happens next on one side, and on the other the one field
* a trial needs, the button that starts it and the terms it starts under. The
* form refuses a bad address in its own words and confirms a good one in a
* status line, naming where the link went.
*
* The form is the one island; this file is a server component, and every
* word, the refusals and the confirmation included, is in
* `./call-to-action-03.data`. The terms line describes the field, so a
* screen reader hears it with the field.
*/
export function CallToAction03() {
const titleId = React.useId()
const termsId = React.useId()
return (
<section
data-section="call-to-action-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="grid items-center gap-8 frame p-6 sm:p-8 lg:grid-cols-2 lg:gap-16 lg:p-12">
<div>
<h2 id={titleId} className="type-display text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 max-w-xl text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<div className="flex flex-col gap-1">
<TrialSignup copy={FORM} describedBy={termsId} />
<p id={termsId} className="text-sm/6 text-pretty text-muted-foreground">
{TERMS.lead}{" "}
<Link href={TERMS.terms.href} className={TERMS_LINK}>
{TERMS.terms.label}
</Link>{" "}
{TERMS.joiner}{" "}
<Link href={TERMS.privacy.href} className={TERMS_LINK}>
{TERMS.privacy.label}
</Link>
.
</p>
</div>
</div>
</div>
</section>
)
}
export default CallToAction03"use client"
import * as React from "react"
import { MailCheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { TrialForm } from "./call-to-action-03.data"
/**
* The trial's one field and its button. The browser's own validation is off
* so the form refuses in its own words: an empty field and a malformed
* address each get a message tied to the field, which is marked invalid and
* takes focus back. An accepted address clears the field and is confirmed in
* the status line under it — always mounted, so the confirmation is announced
* as it arrives — naming where the link went, so a typo shows. The refusal
* and the confirmation never share a slot.
*
* It sends nothing: call your sign-up endpoint where `setSent` is called.
*/
export function TrialSignup({ copy, describedBy }: { copy: TrialForm; describedBy: string }) {
const inputId = React.useId()
const errorId = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const [error, setError] = React.useState<string | null>(null)
const [sent, setSent] = React.useState<string | null>(null)
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const email = String(new FormData(event.currentTarget).get("email") ?? "").trim()
const problem = email === "" ? copy.empty : isEmail(email) ? null : copy.invalid
setError(problem)
if (problem) {
setSent(null)
inputRef.current?.focus()
return
}
setSent(email)
event.currentTarget.reset()
}
return (
<form noValidate onSubmit={submit} className="flex w-full flex-col gap-2">
<Label htmlFor={inputId}>{copy.label}</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
ref={inputRef}
id={inputId}
name="email"
type="email"
autoComplete="email"
placeholder={copy.placeholder}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${errorId} ${describedBy}` : describedBy}
className="h-10 sm:flex-1"
/>
<Button type="submit" size="lg" className="h-10 px-5">
{copy.submit}
</Button>
</div>
{error ? <FieldError id={errorId}>{error}</FieldError> : null}
<p role="status" aria-live="polite" className="min-h-5 text-sm">
{sent ? (
<span className="inline-flex items-start gap-2 text-foreground">
<MailCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{copy.sent.replace("{email}", sent)}
</span>
) : null}
</p>
</form>
)
}/**
* Every word `call-to-action-03` sets: the ask, what happens after it, the
* form's label, refusals and confirmation, and the terms the trial starts
* under. The links name routes of your own site, so replacing this file is
* the whole edit.
*/
export type CtaLink = { label: string; href: string }
/** What the trial form says, handed to its island as plain props. */
export type TrialForm = {
label: string
placeholder: string
submit: string
/** The refusal for a field left empty. */
empty: string
/** The refusal for something that is not an address. */
invalid: string
/** The confirmation; `{email}` is replaced with the address the link went to. */
sent: string
}
export const HEADLINE = "Try Northwind on your own numbers"
export const LEDE =
"Enter your work email and we'll send a link to a workspace with sample data already in it. Connect your own sources when you're ready; the sample data steps aside."
export const FORM: TrialForm = {
label: "Work email",
placeholder: "name@example.com",
submit: "Start free trial",
empty: "Enter your work email to start the trial.",
invalid: "Enter an email address in the format name@example.com.",
sent: "Check your inbox. The link to your workspace is on its way to {email}.",
}
/** The line under the form: what the trial costs, then the two documents it starts under. */
export const TERMS = {
lead: "14 days free, no card. Starting a trial accepts the",
terms: { label: "terms of service", href: "/legal/terms" } satisfies CtaLink,
joiner: "and the",
privacy: { label: "privacy policy", href: "/legal/privacy" } satisfies CtaLink,
}npx shadcn@latest add @vibra/call-to-action-03Call to action with figures
call-to-action-04The claim, where its figures come from and two ways in, beside four figures set large in a field divided by rules.
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { HEADLINE, PRIMARY_ACTION, SECONDARY_ACTION, SOURCE, STATS } from "./call-to-action-04.data"
/**
* The ask backed by numbers: the claim, where its figures come from and the
* two ways in on one side; on the other, four figures in a two-by-two field
* divided by rules, not boxes — each set large in tabular numerals over what
* it counts. The second button leads to the method, because a figure with no
* source is a slogan.
*
* The figures are a description list — each label is its term, each figure
* its detail — drawn figure-first. A server component; every word and figure
* is in `./call-to-action-04.data`.
*/
export function CallToAction04() {
const titleId = React.useId()
return (
<section
data-section="call-to-action-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl gap-12 lg:grid-cols-12 lg:gap-16">
<div className="lg:col-span-5">
<h2 id={titleId} className="type-display text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground tabular-nums">{SOURCE}</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link href={PRIMARY_ACTION.href} className={cn(buttonVariants({ size: "lg" }), "h-10 px-5")}>
{PRIMARY_ACTION.label}
</Link>
<Link
href={SECONDARY_ACTION.href}
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "h-10 px-5")}
>
{SECONDARY_ACTION.label}
</Link>
</div>
</div>
<dl className="grid grid-cols-1 gap-x-10 sm:grid-cols-2 lg:col-span-7 lg:self-center">
{STATS.map((stat) => (
<div key={stat.label} className="flex flex-col-reverse gap-2 border-t border-rule py-6">
<dt className="text-sm/6 text-pretty text-muted-foreground">{stat.label}</dt>
<dd className="type-numeral text-4xl md:text-5xl">{stat.value}</dd>
</div>
))}
</dl>
</div>
</section>
)
}
export default CallToAction04/**
* Every word and figure `call-to-action-04` sets: the claim, where its
* figures come from, the four figures themselves and the two ways in. The
* figures are Northwind's marketing claims — copy, like the rest — so replace
* them with measurements of your own, and say where they came from in
* `SOURCE`.
*/
export type CtaLink = { label: string; href: string }
/** A figure and what it counts, read in that order: "4.1 days — less time to close the month". */
export type Stat = { value: string; label: string }
export const HEADLINE = "Teams on Northwind close the month four days sooner"
export const SOURCE =
"Medians across 1,284 workspaces between April and June 2026, each measured against the team's own first month."
export const STATS: Stat[] = [
{ value: "4.1 days", label: "less time to close the month" },
{ value: "31%", label: "fewer spreadsheet exports each week" },
{ value: "20 min", label: "from sign-up to the first dashboard" },
{ value: "3 in 4", label: "trials connect a second source in their first week" },
]
export const PRIMARY_ACTION: CtaLink = { label: "Start free trial", href: "/sign-up" }
export const SECONDARY_ACTION: CtaLink = { label: "How we measured", href: "/customers/benchmarks" }npx shadcn@latest add @vibra/call-to-action-04Call to action with a customer quote
call-to-action-05The ask and two ways in beside a customer's words: the quote, her face, name, role and company mark, and the two results her team measured.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { LogoMark } from "@/components/ui/logo-mark"
import { HEADLINE, LEDE, PRIMARY_ACTION, QUOTE, RESULTS, SECONDARY_ACTION } from "./call-to-action-05.data"
/**
* The ask with a customer standing behind it: the claim, the lede and two
* ways in — the second to the customer's own story — beside a figure on the
* surface plane that holds what she said, who she is, and the two results her
* team measured, figure first.
*
* The quote is a `figure`: the words a `blockquote`, the person its
* caption — her face (decoration: her name is beside it), her name, her role
* and her company's mark, which says the company's name to a screen reader.
* A server component; every word is in `./call-to-action-05.data`.
*/
export function CallToAction05() {
const titleId = React.useId()
return (
<section
data-section="call-to-action-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl items-center gap-12 lg:grid-cols-12 lg:gap-16">
<div className="lg:col-span-5">
<h2 id={titleId} className="type-display text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link href={PRIMARY_ACTION.href} className={cn(buttonVariants({ size: "lg" }), "h-10 px-5")}>
{PRIMARY_ACTION.label}
</Link>
<Link
href={SECONDARY_ACTION.href}
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "group/story h-10 px-5")}
>
{SECONDARY_ACTION.label}
<ArrowRightIcon
data-icon="inline-end"
aria-hidden="true"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/story:translate-x-0.5 motion-reduce:transition-none"
/>
</Link>
</div>
</div>
<figure className="frame p-6 sm:p-8 lg:col-span-7 lg:p-10">
<blockquote className="text-lg/8 font-medium text-pretty sm:text-xl/8">
<p>“{QUOTE.text}”</p>
</blockquote>
<figcaption className="mt-8 flex items-center gap-3">
<Avatar size="lg">
<AvatarImage src={QUOTE.avatar} alt="" />
<AvatarFallback>{getInitials(QUOTE.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 text-sm">
<p className="font-medium">{QUOTE.name}</p>
<p className="text-muted-foreground">{QUOTE.role}</p>
</div>
<LogoMark name={QUOTE.company} className="text-muted-foreground" />
</figcaption>
<dl className="mt-8 grid grid-cols-2 gap-6 border-t border-border pt-6">
{RESULTS.map((result) => (
<div key={result.label} className="flex flex-col-reverse gap-1">
<dt className="text-sm text-pretty text-muted-foreground">{result.label}</dt>
<dd className="type-numeral text-2xl md:text-3xl">{result.value}</dd>
</div>
))}
</dl>
</figure>
</div>
</section>
)
}
export default CallToAction05/**
* Every word and figure `call-to-action-05` sets: the ask, the customer who
* backs it — what she said, who she is and what changed for her team — and
* the two ways in. The customer is one of the kit's fictional companies,
* drawn by `LogoMark`, and the person gets the kit's face for her name;
* replace both with a customer of your own who agreed to be quoted.
*/
import { avatarFor } from "@/lib/avatars"
import type { LogoName } from "@/components/ui/logo-mark"
export type CtaLink = { label: string; href: string }
/** A result the customer measured, figure first: "40 min — back every Monday". */
export type Result = { value: string; label: string }
export const HEADLINE = "Give your Monday review its first hour back"
export const LEDE =
"Keystone's finance team moved their weekly review onto Northwind in a fortnight. Most teams are set up in an afternoon."
export const QUOTE: { text: string; name: string; role: string; company: LogoName; avatar: string } = {
text: "We used to spend the first forty minutes of every Monday reconciling exports. Now the numbers are on the screen when we sit down, and the meeting is about what to do with them.",
name: "Maren Kovač",
role: "Head of Finance",
company: "Keystone",
/** Her face: the kit's face for her name. Where the picture cannot load, her initials stand in. */
avatar: avatarFor("Maren Kovač"),
}
export const RESULTS: Result[] = [
{ value: "40 min", label: "back every Monday" },
{ value: "5 days", label: "to close the month, down from 9" },
]
export const PRIMARY_ACTION: CtaLink = { label: "Start free trial", href: "/sign-up" }
export const SECONDARY_ACTION: CtaLink = { label: "Read Keystone's story", href: "/customers/keystone" }npx shadcn@latest add @vibra/call-to-action-05Offer strip
call-to-action-06One offer on one strip — an icon, the offer, its terms and one button — for the middle of a pricing page or the top of a changelog.
import * as React from "react"
import Link from "next/link"
import { CalendarCheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { OFFER } from "./call-to-action-06.data"
/**
* The smallest ask: one offer on one strip — an icon, the offer, its terms
* and one button — on the surface plane, for the middle of a pricing page or
* the top of a changelog, where a full band would shout. The button sits at
* the strip's end from `sm` and under the words on a phone.
*
* A server component with no state; every word is in
* `./call-to-action-06.data`.
*/
export function CallToAction06() {
const titleId = React.useId()
return (
<section
data-section="call-to-action-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex flex-col gap-4 frame p-4 sm:flex-row sm:items-center sm:gap-5 sm:py-4 sm:pr-4 sm:pl-5">
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-card text-foreground ring-1 ring-border"
>
<CalendarCheckIcon className="size-5" />
</span>
<div className="min-w-0 flex-1">
<h2 id={titleId} className="text-base font-semibold text-balance">
{OFFER.heading}
</h2>
<p className="mt-0.5 text-sm/6 text-pretty text-muted-foreground">{OFFER.text}</p>
</div>
<Link
href={OFFER.action.href}
className={cn(buttonVariants({ size: "lg" }), "h-10 shrink-0 px-5 max-sm:w-full")}
>
{OFFER.action.label}
</Link>
</div>
</div>
</section>
)
}
export default CallToAction06/**
* Every word `call-to-action-06` sets: one offer, its terms and the one way
* to take it up. The date is copy — the offer's own deadline, in the sample
* world's September — so replace it with your own offer's and the strip says
* that.
*/
export type CtaLink = { label: string; href: string }
export const OFFER = {
heading: "Two months free on yearly billing",
text: "Move any plan to yearly billing before 30 September and the first two months are on us.",
action: { label: "See yearly prices", href: "/pricing?billing=yearly" } satisfies CtaLink,
}npx shadcn@latest add @vibra/call-to-action-06Call to action with two paths
call-to-action-07The ask with two answers side by side: set it up yourself, or talk to the team first — each with who it suits, what comes with it and its one way in.
import * as React from "react"
import Link from "next/link"
import { CheckIcon, MessagesSquareIcon, PlugIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { HEADLINE, LEDE, PHONE, SALES, SELF_SERVE, type Path } from "./call-to-action-07.data"
/**
* One way to start, as a panel: an icon, its name as an h3, who it is for,
* what comes with it and its one link, held to the panel's foot so the two
* buttons line up whatever the lists hold.
*/
function PathPanel({
path,
icon: Icon,
primary,
children,
}: {
path: Path
icon: LucideIcon
primary: boolean
children?: React.ReactNode
}) {
const headingId = React.useId()
return (
<div role="group" aria-labelledby={headingId} className="flex flex-col panel p-6 md:p-8">
<span
aria-hidden="true"
className="flex size-10 items-center justify-center rounded-lg bg-secondary text-secondary-foreground"
>
<Icon className="size-5" />
</span>
<h3 id={headingId} className="mt-5 text-lg font-semibold">
{path.heading}
</h3>
<p className="mt-2 text-sm/6 text-pretty text-muted-foreground">{path.audience}</p>
<ul className="mt-6 flex flex-col gap-3 text-sm">
{path.includes.map((item) => (
<li key={item} className="flex items-start gap-2.5">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{item}
</li>
))}
</ul>
<div className="mt-auto flex flex-wrap items-center gap-x-4 gap-y-3 pt-8">
<Link
href={path.action.href}
className={cn(buttonVariants({ variant: primary ? "default" : "outline", size: "lg" }), "h-10 px-5")}
>
{path.action.label}
</Link>
{children}
</div>
</div>
)
}
/**
* The ask with two answers: set Northwind up alone, or talk to the team
* first. The heading and the lede say which kind of team takes which path;
* the two panels, side by side from `md`, say who each is for, what comes
* with it and how to take it. The self-serve path wears the one accent; the
* sales path is the outline button, with a phone number beside it for the
* readers who would rather call.
*
* Each panel is a group named by its h3. A server component; every word is in
* `./call-to-action-07.data`.
*/
export function CallToAction07() {
const titleId = React.useId()
return (
<section
data-section="call-to-action-07"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-2xl">
<h2 id={titleId} className="type-display text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<div className="mt-10 grid gap-4 md:mt-12 md:grid-cols-2 md:gap-6">
<PathPanel path={SELF_SERVE} icon={PlugIcon} primary />
<PathPanel path={SALES} icon={MessagesSquareIcon} primary={false}>
<p className="text-sm text-muted-foreground">
{PHONE.lead}{" "}
<a
href={PHONE.href}
className="rounded-sm font-medium text-foreground tabular-nums underline-offset-4 focus-ring hover:underline"
>
{PHONE.label}
</a>
</p>
</PathPanel>
</div>
</div>
</section>
)
}
export default CallToAction07/**
* Every word `call-to-action-07` sets: the ask, and the two ways to answer it —
* setting up alone, or talking to the team first — each with who it is for,
* what it includes and its one way in. The links name routes of your own site,
* so replacing this file is the whole edit.
*/
export type CtaLink = { label: string; href: string }
/** One way to start: its name, who it suits, what comes with it and the link that takes it. */
export type Path = {
heading: string
audience: string
includes: string[]
action: CtaLink
}
export const HEADLINE = "Two ways to start"
export const LEDE =
"Most teams set Northwind up themselves in an afternoon. Larger ones usually want a conversation first, and a demo on their own data."
export const SELF_SERVE: Path = {
heading: "Set it up yourself",
audience: "For teams of up to 50. Connect your tools, invite colleagues and add a card when the trial ends.",
includes: ["14 days free, with no card to start", "Guided setup in about 20 minutes", "Monthly or yearly billing"],
action: { label: "Start free trial", href: "/sign-up" },
}
export const SALES: Path = {
heading: "Talk to our team",
audience: "For 50 seats or more, single sign-on, or a contract your procurement team needs to review.",
includes: ["A demo on a copy of your own data", "Single sign-on, SCIM and audit logs", "Invoiced billing and a signed DPA"],
action: { label: "Book a demo", href: "/contact/sales" },
}
/** The other way to reach sales, under their button. */
export const PHONE = { lead: "Or call", label: "+44 20 7946 0321", href: "tel:+442079460321" }npx shadcn@latest add @vibra/call-to-action-07Inverse call to action with the first steps
call-to-action-08The page's one dark panel: the ask and two ways in beside the three numbered steps from sign-up to a first review.
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { HEADLINE, LEDE, PRIMARY_ACTION, SECONDARY_ACTION, STEPS, STEPS_LABEL } from "./call-to-action-08.data"
/**
* The ask as the page's one dark panel: the claim, the lede and two ways in
* beside the three steps from sign-up to a first review, numbered because
* they are a sequence.
*
* The panel is inverse by token, not by colour: it carries the `dark` class,
* so every token inside it — the ground, the inks, the rules, the button —
* resolves to the palette's own dark values, a preset's included. On a light
* page it is the dark plane; on a dark page it is the card plane, lifted a
* step off the page and ringed. A server component; every word is in
* `./call-to-action-08.data`.
*/
export function CallToAction08() {
const titleId = React.useId()
const stepsId = React.useId()
return (
<section
data-section="call-to-action-08"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div
data-slot="call-to-action-panel"
className="dark grid gap-10 rounded-2xl panel p-6 text-foreground sm:p-10 lg:grid-cols-2 lg:gap-16 lg:p-14"
>
<div className="flex flex-col items-start">
<h2 id={titleId} className="type-display text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 max-w-md text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link href={PRIMARY_ACTION.href} className={cn(buttonVariants({ size: "lg" }), "h-10 px-5")}>
{PRIMARY_ACTION.label}
</Link>
<Link
href={SECONDARY_ACTION.href}
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "h-10 px-5")}
>
{SECONDARY_ACTION.label}
</Link>
</div>
</div>
<div>
<p id={stepsId} className="type-eyebrow">
{STEPS_LABEL}
</p>
<ol aria-labelledby={stepsId} className="mt-4">
{STEPS.map((step, index) => (
<li key={step.title} className="flex gap-4 border-t border-border py-5 last:pb-0">
<span
aria-hidden="true"
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-secondary text-sm font-medium text-secondary-foreground tabular-nums"
>
{index + 1}
</span>
<div className="min-w-0">
<p className="font-medium">{step.title}</p>
<p className="mt-1 text-sm/6 text-pretty text-muted-foreground">{step.text}</p>
</div>
</li>
))}
</ol>
</div>
</div>
</div>
</section>
)
}
export default CallToAction08/**
* Every word `call-to-action-08` sets: the ask, what the trial includes, the
* three steps from sign-up to a first review, and the two ways in. The links
* name routes of your own site, so replacing this file is the whole edit.
*/
export type CtaLink = { label: string; href: string }
/** One step between signing up and the first review, in the order a team takes them. */
export type Step = { title: string; text: string }
export const HEADLINE = "Put next Monday's review on Northwind"
export const LEDE =
"Every feature is on during the trial, and a person answers the chat if the setup stalls."
export const STEPS_LABEL = "From sign-up to the first review"
export const STEPS: Step[] = [
{ title: "Connect a source", text: "Billing, your CRM or the support desk. Access is read-only, and each takes about five minutes." },
{ title: "Pick a starting dashboard", text: "Revenue, retention or support load, built from what Northwind found in your sources." },
{ title: "Invite the people in the review", text: "They see the same numbers you do from their first sign-in." },
]
export const PRIMARY_ACTION: CtaLink = { label: "Start free trial", href: "/sign-up" }
export const SECONDARY_ACTION: CtaLink = { label: "Talk to sales", href: "/contact/sales" }npx shadcn@latest add @vibra/call-to-action-08