Call-to-action section
A closing call-to-action section: a dark band with a display-size headline, one filled action and an optional text-link secondary. Centred or start-aligned, with optional media, or plain on the page.
cmp_cta_section_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_cta_section_01 version 1.0.0 with variant "green", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Green accent
- Version
- 1.0.0
- Digest
Full digest
sha256-c9797705bb94dc2564d2ec17d07fae845733ebbb77114e545459f8a221e8eb54
<script module lang="ts">
export interface CtaAction {
/** Link text. It must make sense on its own, so name the destination, not "Click here". */
label: string;
href: string;
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Headline: the specific proposition. */
title: string;
/** The one filled action. */
primaryAction: CtaAction;
/** Optional quieter link beside the primary action. */
secondaryAction?: CtaAction;
/** Short label above the heading. */
eyebrow?: string;
/** One or two sentences under the heading. */
description?: string;
/** Centre a short column, or set the copy on the start edge with the actions opposite it. */
align?: 'center' | 'start';
/** Paint the dark band, or sit plainly on the page. */
band?: boolean;
headingLevel?: 2 | 3;
/** Image or illustration: beside the copy when start-aligned, below it when centred. */
media?: Snippet;
}
let {
title,
primaryAction,
secondaryAction,
eyebrow,
description,
align = 'center',
band = true,
headingLevel = 2,
media
}: Props = $props();
const uid = $props.id();
const heading = $derived(`h${headingLevel}`);
const centred = $derived(align === 'center');
// Start-aligned with media is the split; start-aligned without it sets the actions opposite
// the copy from lg, so a wide band never strands a narrow column on its start edge.
const split = $derived(!centred && !!media);
const justified = $derived(!centred && !media);
</script>
<!--
The move: a dark band on a light page, with the headline set at display scale and one
light-filled action as the only bright object on it. The secondary action is a text link with
an arrow, never a second filled button, so the band has exactly one thing to press.
Start alignment without media sets the copy on the start edge and the actions opposite it,
aligned to the copy's last line. A start-aligned stack alone in a 1152px band would leave the
whole end half empty (DESIGN §2, no stranded column).
The row wraps rather than squeezes: the copy keeps at least 28rem, and actions too wide to sit
beside that drop below the copy on its start edge instead of narrowing the headline.
Spacing, per breakpoint, inside the band: 8 (eyebrow to title, between actions), 16 (title
to description), 32 (copy to actions), the band's inline padding, which doubles as the gap to
media, and its block padding. Five values.
-->
<section
class={[
'cta-section px-4 py-16 sm:px-6 sm:py-24 lg:px-8 lg:py-32',
!band && 'cta-section--plain'
]}
aria-labelledby="{uid}-title"
>
<div
class={[
'cta-section__band mx-auto max-w-6xl',
band && 'rounded-2xl bg-[var(--_surface)] px-6 py-16 sm:p-16 lg:py-24',
split && 'grid gap-16 lg:grid-cols-2 lg:items-center',
justified && 'lg:flex lg:flex-wrap lg:items-end lg:justify-between lg:gap-x-16 lg:gap-y-8'
]}
>
<!-- Centred from sm only: below it the lede wraps to four lines or more, and DESIGN §4 never
centres a paragraph that long, so the phone layout keeps one start edge instead. -->
<div
class={[
'min-w-0',
centred ? 'mx-auto max-w-3xl text-start sm:text-center' : 'max-w-2xl text-start',
justified && 'lg:flex-[1_1_28rem]'
]}
>
{#if eyebrow}
<p
class="cta-section__eyebrow mb-2 text-xs leading-4 font-semibold tracking-[0.06em] text-balance break-words text-[var(--_muted)] uppercase"
>
{eyebrow}
</p>
{/if}
<svelte:element
this={heading}
id="{uid}-title"
class="cta-section__title text-3xl leading-[1.15] font-semibold tracking-[-0.02em] text-balance break-words text-[var(--_ink)] sm:text-4xl lg:text-5xl lg:leading-[1.08] lg:tracking-[-0.022em]"
>
{title}
</svelte:element>
{#if description}
<p
class={[
'cta-section__lede mt-4 max-w-[34rem] text-base leading-[1.5] text-pretty break-words text-[var(--_muted)] sm:text-lg',
centred && 'sm:mx-auto'
]}
>
{description}
</p>
{/if}
{#if !justified}
{@render actions()}
{/if}
</div>
{#if justified}
{@render actions()}
{/if}
{#if media}
<div
class={[
'cta-section__media min-w-0 overflow-hidden rounded-xl',
centred && 'mx-auto mt-16 max-w-4xl'
]}
>
{@render media()}
</div>
{/if}
</div>
</section>
{#snippet actions()}
<div
class={[
'mt-8 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-8',
centred && 'sm:justify-center',
justified && 'lg:mt-0 lg:flex-none'
]}
>
<a
href={primaryAction.href}
class="cta-section__primary inline-flex min-h-12 w-full items-center justify-center rounded-lg bg-[var(--_accent)] px-6 py-2 text-center text-base leading-6 font-medium text-[var(--_on-accent)] hover:bg-[var(--_accent-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] active:scale-[.98] motion-reduce:active:scale-100 sm:w-auto"
>
<span class="min-w-0 wrap-anywhere">{primaryAction.label}</span>
</a>
{#if secondaryAction}
<a
href={secondaryAction.href}
class="cta-section__secondary group inline-flex min-h-12 w-full items-center justify-center rounded-lg py-2 text-center text-base leading-6 font-medium text-[var(--_ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] sm:w-auto"
>
<!-- The arrow is inline and joined to the last word, so a label that wraps carries its
arrow on its last line instead of leaving it stranded at the link's far edge. The
link has no inline padding, so its text starts on the same edge as the copy. -->
<span class="min-w-0 wrap-anywhere"
><span class="cta-section__secondary-label">{secondaryAction.label}</span>⁠<svg
class="cta-section__arrow ms-2 inline-block size-4 align-[-0.125em] text-[var(--_muted)] group-hover:text-[var(--_ink)] rtl:-scale-x-100"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M3 8h10M9 4l4 4-4 4" />
</svg></span
>
</a>
{/if}
</div>
{/snippet}
<style>
/* Public tokens: set --cta-section-* on this section or any ancestor to retone it. The band
defaults are a zinc-950 surface with near-white type and a near-white action, so the
fallback render is monochrome until a consumer chooses an accent. */
.cta-section {
--_surface: var(--cta-section-surface, #09090b);
--_accent: var(--cta-section-accent, #15803d);
--_on-accent: var(--cta-section-on-accent, #ffffff);
--_ink: var(--cta-section-ink, #fafafa);
--_muted: var(--cta-section-muted, #a1a1aa);
--_hairline: var(--cta-section-hairline, rgb(255 255 255 / 0.1));
/* Derived so one accent drives the button and its hover: one step darker. Darkening, not a
step toward the label colour, because a mid-tone accent with white text (the blue and
green palettes) would lose contrast as it lightened; darker only ever widens the gap. */
--_accent-hover: color-mix(in oklab, var(--_accent) 84%, #000);
}
/* Without the band the section sits on the consumer's page, so the same public variables
fall back to the light page defaults instead. */
.cta-section--plain {
--_surface: var(--cta-section-surface, transparent);
--_accent: var(--cta-section-accent, #15803d);
--_on-accent: var(--cta-section-on-accent, #ffffff);
--_ink: var(--cta-section-ink, #09090b);
--_muted: var(--cta-section-muted, #52525b);
--_hairline: var(--cta-section-hairline, rgb(0 0 0 / 0.08));
}
/* §3.5's surface on dark: a lit top edge and a hairline, no black shadow, because the band
sits on the page rather than floating above it. */
.cta-section:not(.cta-section--plain) .cta-section__band {
box-shadow:
inset 0 0 0 1px var(--_hairline),
inset 0 1px 0 rgb(255 255 255 / 0.08);
}
/* The media frame draws its hairline above whatever the consumer puts inside it. */
.cta-section__media {
position: relative;
}
.cta-section__media::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 1px var(--_hairline);
pointer-events: none;
}
.cta-section__primary {
transition:
background-color 140ms cubic-bezier(0.2, 0, 0, 1),
transform 80ms cubic-bezier(0.2, 0, 0, 1);
}
/* The secondary link has no resting underline (DESIGN §2); on hover the underline arrives and
the arrow steps one notch toward where the link goes. */
.cta-section__secondary-label {
text-decoration-line: underline;
text-decoration-color: transparent;
text-decoration-thickness: 1px;
text-underline-offset: 4px;
transition: text-decoration-color 140ms cubic-bezier(0.2, 0, 0, 1);
}
.cta-section__secondary:hover .cta-section__secondary-label {
text-decoration-color: currentColor;
}
.cta-section__arrow {
transition: color 140ms cubic-bezier(0.2, 0, 0, 1);
}
/* Movement only where motion is welcome, so reduced motion removes it in both directions. */
@media (prefers-reduced-motion: no-preference) {
.cta-section__arrow {
transition:
color 140ms cubic-bezier(0.2, 0, 0, 1),
translate 140ms cubic-bezier(0.2, 0, 0, 1);
}
.cta-section__secondary:hover .cta-section__arrow {
translate: 2px 0;
}
.cta-section__secondary:dir(rtl):hover .cta-section__arrow {
translate: -2px 0;
}
}
/* Arabic and Hebrew join or shape their letters, so tracking resets and the eyebrow drops its
caps treatment. The lede's measure tightens because Arabic sets wider. */
.cta-section:dir(rtl) .cta-section__title,
.cta-section:dir(rtl) .cta-section__eyebrow {
letter-spacing: 0;
text-transform: none;
}
.cta-section:dir(rtl) .cta-section__title {
line-height: 1.3;
}
/* Without caps, 12px Arabic reads as a whisper beside the Latin eyebrow; one step up. */
.cta-section:dir(rtl) .cta-section__eyebrow {
font-size: 0.8125rem;
}
.cta-section:dir(rtl) .cta-section__lede {
max-width: 32rem;
}
/* Japanese, Chinese and Korean headings: no Latin display tracking, and wrap at phrase
boundaries where the browser supports it rather than mid-word. */
.cta-section__title:lang(ja),
.cta-section__title:lang(zh),
.cta-section__title:lang(ko) {
letter-spacing: 0;
line-height: 1.25;
}
.cta-section__title:lang(ja),
.cta-section__title:lang(zh),
.cta-section__lede:lang(ja),
.cta-section__lede:lang(zh) {
line-break: strict;
word-break: auto-phrase;
}
/* Korean spaces its words, so it keeps each one whole instead. */
.cta-section__title:lang(ko),
.cta-section__lede:lang(ko) {
word-break: keep-all;
}
@media (prefers-reduced-motion: reduce) {
.cta-section__primary,
.cta-section__arrow {
transition: none;
}
}
</style>
Usage#
On this pagePresentational: renders a heading, optional eyebrow and description, one primary link and an optional secondary link, plus an optional media snippet. Both actions are ordinary links. It does not track clicks, open forms or modals, or load and size your media.
- Suggested location
src/lib/components/cta-section-01- Required props
titleprimaryAction
Limitations
- Actions are links only. For a form, a modal or a tracked click, wrap or replace them in your own code.
- Media is whatever the snippet renders. The component frames and clips it to 12px corners but does not size, lazy-load or describe it; give images width, height and alt text.
- The band is one appearance: a dark surface by default, or plain with light defaults. It is not a light and dark mode pair; retone it through the CSS variables.
- No typeface is set: the component inherits the fonts of the page you place it on.
Example
<script lang="ts">
import CtaSection from '$lib/components/cta-section-01/CtaSection.svelte';
</script>
<CtaSection
eyebrow="Halcyon for teams"
title="Plan next quarter before this one ends"
description="Boards, timelines and guest access in one workspace. Starter is free for up to three people."
primaryAction={{ label: 'Start a free Halcyon board', href: '/signup' }}
secondaryAction={{ label: 'Compare Halcyon plans', href: '/pricing' }}
/>Call-to-action section#
A closing section with one proposition and one action. Use it near the end of a landing page, or between long content blocks, when there is a single next step worth pressing.
Layouts#
align="center"(the default) centres a short column: eyebrow, headline, a description of two lines at most at desktop width, and the actions. Longer copy belongs inalign="start"; a centred paragraph of three lines or more is hard to read. Belowsmthe copy sits on the start edge in both layouts.align="start"sets the copy on the start edge. Without media, the actions sit opposite the copy fromlg, lined up with its last line, so a wide band never leaves half of itself empty. With media, the media sits beside the copy fromlg.- Centred with media puts the media below the copy at every width.
- Below
smthe actions stack full width with the primary on top.
Band or plain#
band (default true) paints a dark rounded band inside the page container. band={false}
drops the surface and the colours fall back to light-page defaults, for pages that already
have a coloured section behind this one or want a quieter close.
Media#
<CtaSection
title="See every deadline on one timeline"
primaryAction={{ label: 'Try Halcyon timelines free', href: '/signup' }}
align="start"
>
{#snippet media()}
<img
src="/timeline.png"
alt="A timeline with five cards across six weeks"
width="1200"
height="800"
class="block h-auto w-full"
/>
{/snippet}
</CtaSection>What it does not do#
It renders links. It does not track clicks, open forms or modals, or submit anything. For an email capture use a newsletter or waitlist component instead.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
title | string | Yes | None | Headline: the specific proposition, set at display size and balanced across lines. |
primaryAction | { label: string; href: string } | Yes | None | The one filled action, rendered as a link. The label must make sense on its own, so name the destination rather than writing 'Click here'. |
secondaryAction | { label: string; href: string } | No | None | Optional quieter link with an arrow beside the primary. Omitted, nothing renders in its place. |
eyebrow | string | No | None | Short uppercase label above the heading. |
description | string | No | None | One or two sentences under the heading, held to a readable measure. |
align | 'center' | 'start' | No | 'center' | center centres a short column: keep the description to two lines at desktop width. start sets the copy on the start edge; without media the actions sit opposite it from lg, with media the media sits beside it. Below sm both set the copy on the start edge. |
band | boolean | No | true | true paints the dark band; false sits plainly on the page and the colour variables fall back to light-page defaults. |
headingLevel | 2 | 3 | No | 2 | Level of the heading, so the section fits the page outline. |
media | Snippet | No | None | Optional image or illustration. Beside the copy from lg when align is start, below the copy when align is center, and below the copy on phones either way. |
Customization#
On this pageSet the copy and links through props, pass media as a snippet, and retone the band through six CSS variables. The accent drives the primary action, its hover and the focus ring.
- Copy: the title states a specific proposition and the description says what happens next. Keep the description to one or two sentences.
- Links:
primaryActionandsecondaryActiontake a label and an href. Name the destination in the label so it reads on its own. - Accent: set
--cta-section-accentand--cta-section-on-accent. On the band the default is near-white with near-black text; plain,zinc-800with near-white text. The hover shade is the accent one step darker, and the focus ring follows the accent. - Band colours:
--cta-section-surface,--cta-section-ink,--cta-section-mutedand--cta-section-hairline. For a tinted band (a deep green for a garden centre, say), set the surface to the tint, keep ink near-white and pick a muted that clears 4.5:1 against it. - Plain on a coloured page: set band=
{false}, then set--cta-section-inkand--cta-section-mutedto the page's own text colours. - Layout: align="center" for a short closing statement whose description runs to two lines at most; align="start" for longer copy, to set the actions opposite it, or to put media beside it. Below sm both layouts set the copy on the start edge.
- Media: pass a snippet holding an img with width, height and alt, or a drawn product surface. It is clipped to 12px corners with a hairline drawn over it.
Public CSS variables
| Variable | Token |
|---|---|
--cta-section-accent | accent |
--cta-section-on-accent | onAccent |
--cta-section-surface | surface |
--cta-section-ink | ink |
--cta-section-muted | muted |
--cta-section-hairline | hairline |
--cta-section-accent | plainAccent |
--cta-section-on-accent | plainOnAccent |
Accessibility#
On this page- The section is a landmark labelled by its heading through
aria-labelledby; the id comes from$props.id(), so several sections on one page stay uniquely labelled. - Both actions are links (<a href>) because they navigate. Write labels that stand alone out of context; avoid 'Click here' and 'Learn more'.
- Exactly one action is filled. The secondary is a text link whose arrow is
aria-hidden. - Both links show a two-pixel focus-visible outline in the accent colour with a two-pixel offset, and each is at least 48px tall.
- Default contrast on the
#09090bband: ink#fafafaabout 19:1, muted#a1a1aaabout 7.8:1, primary text#09090bon#fafafaabout 19:1. Plain on white: ink#09090babout 20:1, muted#52525babout 7.7:1, primary text#fafafaon#27272aabout 14:1. - The blue (
#2563eb) and green (#15803d) palettes carry white text at about 5.2:1 and 5.0:1. The hover shade only darkens the accent, so it never lowers that ratio. - Headings and the eyebrow reset their letter-spacing under dir="rtl", the eyebrow drops its caps treatment there, and the arrow flips with the direction.
- Media is the consumer's: give images alt text, or mark a purely decorative surface
aria-hidden.
Known limitations
- Contrast is computed for the shipped defaults and the two palettes; re-check any colour you change at 4.5:1 for text.
- The press uses a small scale, which is removed under prefers-reduced-motion, as is the arrow's hover nudge.
- Japanese, Chinese and Korean headings use word-break: auto-phrase, which only some browsers support; others fall back to ordinary line breaking.
- The hover shade is derived with color-mix(), inside Tailwind 4's own browser baseline (Safari 16.4, Chrome 111, Firefox 128).
Release details#
On this page- Integration
- Presentational
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 30 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.