Arc CTA Section
A closing call-to-action section with an arc of images curving over the headline, a pill button and a three-up reassurance row.
Live preview and source code for Arc CTA Section
"use client"
import React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { ArrowRight, ImageIcon } from "lucide-react";
export interface ArcCtaFeature {
/** Short title, set in the same serif as the headline */
title: string;
/** One sentence under the title */
description: string;
}
export interface ArcCtaSectionProps {
/** Sources laid out along the arc, left to right. `null` renders an empty placeholder tile */
images?: (string | null)[];
/** Headline under the arc */
headline?: string;
/** Supporting sentence under the headline */
subline?: string;
/** Label of the call to action. Pass `null` to hide the button */
ctaLabel?: string | null;
/** Destination for the call to action. Without it the CTA renders as a button */
ctaHref?: string;
/** Row under the button. Pass an empty array to hide it */
features?: ArcCtaFeature[];
/** Angle of the first card, in degrees clockwise from the top of the arc */
startAngle?: number;
/** Angle of the last card, in degrees clockwise from the top of the arc */
endAngle?: number;
/** Extra classes for consumers using a utility CSS framework */
className?: string;
}
/** Ten empty slots, so the block renders its full arc before any image exists. */
const PLACEHOLDER_IMAGES: (string | null)[] = Array.from({ length: 10 }, () => null);
const DEFAULT_FEATURES: ArcCtaFeature[] = [
{ title: "Quick to start", description: "Up and running in a few minutes." },
{ title: "Made to fit", description: "Adapts to the way you already work." },
{ title: "Here to help", description: "Real people answer, usually same day." },
];
const fadeUp = {
hidden: { opacity: 0, y: 14 },
visible: { opacity: 1, y: 0 },
};
/**
* ArcCtaSection — a closing call to action: a semicircle of images
* curving over the headline, the button and a three-up reassurance
* row. Renders standalone with zero props.
*/
export default function ArcCtaSection({
images = PLACEHOLDER_IMAGES,
headline = "Ready to make something worth sharing",
subline = "Everything you need to get started is already here — no setup, no waiting.",
ctaLabel = "Get started today",
ctaHref,
features = DEFAULT_FEATURES,
startAngle = -85,
endAngle = 95,
className = "",
}: ArcCtaSectionProps) {
const reduceMotion = useReducedMotion();
// Spread the cards evenly across the sweep. One card sits at the start angle
// rather than dividing by zero; the step only means anything from two up.
const step = images.length > 1 ? (endAngle - startAngle) / (images.length - 1) : 0;
const ctaContent = (
<>
{ctaLabel}
<ArrowRight size={17} strokeWidth={2} aria-hidden="true" />
</>
);
return (
<section className={`acs-root ${className}`.trim()}>
{/* Instrument Serif is the headline face. Requesting it from the block
rather than from a global stylesheet is what keeps the request on the
pages that actually render this section — a site that never mounts it
never asks Google for it. Delete these three tags and the stack falls
back to the platform serif. */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap"
/>
<div className="acs-stage">
{/* Decoration: the cards are a sample wall rather than content, so the
whole arc is hidden from assistive tech in one place. */}
<div className="acs-arc" aria-hidden="true">
<div className="acs-pivot">
{images.map((src, i) => {
const angle = startAngle + step * i;
return (
<div
key={i}
className="acs-slot"
style={{ transform: `rotate(${angle}deg) translateY(calc(var(--acs-r) * -1))` }}
>
<motion.div
className="acs-card"
initial={reduceMotion ? false : { opacity: 0, scale: 0.82 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.05 * i, ease: [0.22, 1, 0.36, 1] }}
>
{src ? (
<img className="acs-img" src={src} alt="" loading="lazy" decoding="async" />
) : (
<span className="acs-placeholder">
<ImageIcon className="acs-placeholder-icon" strokeWidth={1.5} />
</span>
)}
</motion.div>
</div>
);
})}
</div>
</div>
<motion.div
className="acs-inner"
initial={reduceMotion ? false : "hidden"}
animate="visible"
transition={{ delayChildren: 0.14, staggerChildren: 0.08 }}
>
{/* An h2: this section closes a page, it does not open one, so the
page's own h1 is somewhere above it. */}
<motion.h2 className="acs-headline" variants={fadeUp} transition={{ duration: 0.55, ease: "easeOut" }}>
{headline}
</motion.h2>
{subline && (
<motion.p className="acs-subline" variants={fadeUp} transition={{ duration: 0.55, ease: "easeOut" }}>
{subline}
</motion.p>
)}
{ctaLabel && (
<motion.div variants={fadeUp} transition={{ duration: 0.55, ease: "easeOut" }}>
{ctaHref ? (
<a className="acs-cta" href={ctaHref}>
{ctaContent}
</a>
) : (
<button className="acs-cta" type="button">
{ctaContent}
</button>
)}
</motion.div>
)}
{features.length > 0 && (
<motion.ul className="acs-features" variants={fadeUp} transition={{ duration: 0.55, ease: "easeOut" }}>
{features.map((feature) => (
<li className="acs-feature" key={feature.title}>
<h3 className="acs-feature-title">{feature.title}</h3>
<p className="acs-feature-text">{feature.description}</p>
</li>
))}
</motion.ul>
)}
</motion.div>
</div>
<style>{`
.acs-root {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
background: #ffffff;
color: #191919;
font-family: inherit;
/* Every dimension below is a share of this section's own width, so the
arc keeps its proportions in a narrow column as well as full bleed. */
container-type: inline-size;
}
.acs-stage {
/* Cards are spaced by angle, so the gap between two of them is the
arc step minus the card: the radius has to stay well ahead of the
width or the corners of neighbours collide as they rotate. */
--acs-r: clamp(190px, 46cqw, 600px);
--acs-card-w: clamp(56px, 11.7cqw, 150px);
--acs-card-h: calc(var(--acs-card-w) * 4 / 3);
--acs-card-radius: clamp(9px, 1.6cqw, 20px);
/* Distance from the top of the section to the pivot, i.e. what it
takes to clear the whole arc. Only the narrow layout uses it. */
--acs-arc-top: calc(var(--acs-r) + var(--acs-card-h) / 2 + 22px);
--acs-serif: "Instrument Serif", ui-serif, Georgia, "Times New Roman", serif;
position: relative;
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: flex-end;
align-items: center;
box-sizing: border-box;
width: 100%;
/* The arc, not the copy, sets the height: the radius, plus half a card
for the topmost one, plus a margin above it. */
min-height: calc(var(--acs-r) + var(--acs-card-h) / 2 + clamp(28px, 6cqw, 74px));
padding: calc(var(--acs-r) * 0.55) clamp(18px, 4cqw, 48px) clamp(26px, 3.4cqw, 44px);
}
/* The arc layer spans the section only so it can carry the mask: the
cards that run off the bottom edge dissolve into the background
instead of ending on the straight cut of the section. */
.acs-arc {
position: absolute;
inset: 0;
z-index: 0;
-webkit-mask-image: linear-gradient(to bottom, #000 76%, transparent 99%);
mask-image: linear-gradient(to bottom, #000 76%, transparent 99%);
}
/* A zero-size pivot pinned near the bottom edge. Each card rotates
around it and is then pushed out by the radius, which leaves it
sitting tangential to the circle. */
.acs-pivot {
position: absolute;
left: 50%;
bottom: 2.5cqw;
width: 0;
height: 0;
}
.acs-slot {
position: absolute;
left: 0;
top: 0;
width: var(--acs-card-w);
height: var(--acs-card-h);
margin-left: calc(var(--acs-card-w) / -2);
margin-top: calc(var(--acs-card-h) / -2);
}
.acs-card {
width: 100%;
height: 100%;
overflow: hidden;
border-radius: var(--acs-card-radius);
background: #f2f2f3;
box-shadow: 0 4px 16px rgba(25, 25, 25, 0.06);
}
.acs-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.acs-placeholder {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
height: 100%;
background: linear-gradient(160deg, #f5f5f6 0%, #eaeaec 100%);
border: 1px solid rgba(25, 25, 25, 0.05);
border-radius: inherit;
}
.acs-placeholder-icon {
width: 24%;
height: 24%;
color: #b9b9be;
}
.acs-inner {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
}
.acs-headline {
margin: 0;
/* In Instrument Serif the default headline runs 8.2em to the end of
its third word and 10.3em to the end of the fourth, so this is what
lands the break in the middle rather than leaving a word stranded. */
max-width: 10em;
font-family: var(--acs-serif);
font-size: clamp(27px, 3.5cqw, 46px);
font-weight: 400;
line-height: 1.16;
letter-spacing: -0.01em;
}
.acs-subline {
margin: clamp(12px, 1.5cqw, 20px) 0 0;
max-width: 42em;
font-size: clamp(13px, 1.22cqw, 16px);
line-height: 1.55;
color: #77777d;
text-wrap: pretty;
}
.acs-cta {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: clamp(18px, 2.2cqw, 28px);
padding: clamp(11px, 1.15cqw, 15px) clamp(20px, 2cqw, 27px);
font-family: inherit;
font-size: clamp(13px, 1.25cqw, 16px);
font-weight: 500;
line-height: 1;
color: #ffffff;
text-decoration: none;
background: #191919;
border: 0;
border-radius: 9999px;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
}
.acs-cta:hover {
background: #000000;
transform: translateY(-1px);
}
.acs-cta:focus-visible {
outline: 2px solid #191919;
outline-offset: 3px;
}
.acs-features {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: clamp(16px, 2.4cqw, 30px);
width: 100%;
max-width: clamp(400px, 50cqw, 620px);
margin: clamp(24px, 3.2cqw, 42px) 0 0;
padding: 0;
list-style: none;
}
.acs-feature {
padding: 0 clamp(6px, 1cqw, 12px);
}
.acs-feature + .acs-feature {
border-left: 1px solid #e7e7e9;
}
.acs-feature-title {
margin: 0;
font-family: var(--acs-serif);
font-size: clamp(13px, 1.3cqw, 16px);
font-weight: 400;
line-height: 1.3;
}
.acs-feature-text {
margin: clamp(5px, 0.7cqw, 9px) 0 0;
font-size: clamp(11px, 1cqw, 13px);
line-height: 1.5;
color: #8a8a90;
text-wrap: pretty;
}
/* Narrow: there is no clearing left inside the arc for the copy to sit
in, so the arc moves to the top of the section and the copy runs
underneath it. The radius widens at the same time, which swings the
ends of the arc off the sides rather than into the text. */
@container (max-width: 640px) {
.acs-stage {
--acs-r: 86cqw;
--acs-card-w: 21cqw;
--acs-card-radius: 2.6cqw;
justify-content: flex-start;
min-height: 0;
/* The ends of the arc are off the sides here, so the copy only has
to clear the cards still standing over the text column, not the
whole radius. */
padding: calc(var(--acs-arc-top) - 40cqw) 20px 44px;
}
.acs-pivot {
top: var(--acs-arc-top);
bottom: auto;
}
/* Nothing is cut off at the bottom in this layout — the ends of the
arc run off the sides — so the fade has nothing to soften. */
.acs-arc {
-webkit-mask-image: none;
mask-image: none;
}
.acs-features {
grid-template-columns: minmax(0, 1fr);
max-width: 22em;
gap: 18px;
}
.acs-feature {
padding: 0;
}
.acs-feature + .acs-feature {
border-left: 0;
border-top: 1px solid #e7e7e9;
padding-top: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.acs-cta {
transition: none;
}
}
`}</style>
</section>
);
}
Ready to make something worth sharing
Everything you need to get started is already here — no setup, no waiting.
Quick to start
Up and running in a few minutes.
Made to fit
Adapts to the way you already work.
Here to help
Real people answer, usually same day.
The call-to-action section a page ends on: one line, one button, and an arc of images curving over the top of both. It is the pre-footer slot — the last thing someone reads before the links and the copyright — and the arc is doing the work a wall of screenshots usually does, without asking anyone to study it. The cards are placed by angle around a single pivot rather than positioned by hand, so the sweep stays even at any count, and it ships with placeholder tiles so the section looks right before the artwork exists.
Installation
Copy the source from the Code tab above into your project as
ArcCtaSection.tsx. Its styles are a <style> block of acs- prefixed
classes that travels with the file, so there is no stylesheet to import and no
Tailwind config to change.
It imports framer-motion for the entrance and lucide-react for the arrow
and the placeholder icon:
npm install framer-motion lucide-reactThe headline is set in Instrument Serif, and the section requests that font
itself through three <link> tags at the top of its markup rather than through
a global stylesheet. That is deliberate: the request goes out on the pages that
render this section and nowhere else. Delete those three tags and the stack
falls back to the platform serif, which is the whole of what it takes to drop
the dependency.
The file starts with "use client" — it uses hooks, so in the Next.js app
router it has to stay a client component.
Usage
Every prop is optional, and the defaults are deliberately generic — replace all of them. Pass your images in the order they should run around the arc, left to right:
import ArcCtaSection from "./components/ArcCtaSection";
export default function Example() {
return (
<ArcCtaSection
images={["/work/1.jpg", "/work/2.jpg", "/work/3.jpg", null, null]}
headline="Ready to start your first project"
ctaLabel="Book a call"
ctaHref="/contact"
features={[
{ title: "No commitment", description: "Cancel any time, no notice period." },
{ title: "Fixed pricing", description: "One number, agreed before we begin." },
{ title: "Fast turnaround", description: "First draft inside a week." },
]}
/>
);
}The number of cards is the length of images, and they spread evenly between
startAngle and endAngle (−85° and 95° by default, measured clockwise from
the top). Any entry that is null renders the grey placeholder tile, so you can
fill the arc in as the pictures arrive.
With ctaHref the button is an <a>. Without it you get a
<button type="button"> that does nothing when clicked — wire it up, or give it
an href. features takes any number of items, three being what the row is
drawn for; pass [] to drop it and ctaLabel={null} to drop the button.
When to reach for it
The bottom of a long page, where someone has read everything and needs one obvious next step. The three short columns under the button are the last objections — price, commitment, effort — answered in a sentence each.
Any page with a body of visual work behind it: a studio, a photographer, a product with a gallery, a tool that makes things. The arc says look how much of this there is in a way a grid of thumbnails at the bottom of a page never manages.
A section that has to hold up at any width. Sizes are set in container query units, so the arc is scaled from the width of the section rather than the window — it keeps its proportions inside a narrow column, and does not need a viewport to itself.
When not to
At the top of a page. The headline is an <h2>, the arc reads as a summing
up rather than an opening, and the whole thing assumes a reader who has already
been convinced. Modern Hero is the one that opens.
If you have fewer than about six images. The sweep is fixed, so a short array spaces the cards far apart and the curve stops reading as a curve. A plain centred headline and a CTA Button will look deliberate where a sparse arc looks broken.
If the images are the content. They are decoration — the whole arc is
aria-hidden, every tile is cropped to a tall 3:4 and rotated, and the ones at
the bottom fade out mid-image. Anything a visitor is meant to study or click
belongs in a gallery, not here.
Accessibility
The arc is marked aria-hidden, so a screen reader gets the headline, the
sentence, the button and the feature list and nothing else. If your images
carry meaning, remove that attribute and give each one a real alt — the
component renders alt="" on every card.
The headline is an <h2> and each feature title an <h3>, which is the right
shape for a section closing a page rather than opening one. The features are a
real <ul>, so the three points are announced as a list of three.
The entrance is gated on prefers-reduced-motion through framer-motion’s
useReducedMotion: with the setting on, the cards and the copy start at their
final values instead of fading in, and the button drops its hover transition.
Notes
Each card is rotated by its own angle, which is what makes the ends of the arc
lie on their sides — a card at 90° reads as landscape while the ones at the top
read as portrait. Crop for the top of the arc and let the ends fall where they
land, or narrow the sweep with startAngle and endAngle.
The gap between two cards is the arc step minus the card, so adding images
without widening the sweep eventually closes it: past about fourteen the corners
of neighbours start to touch as they rotate. Widen startAngle and endAngle
to buy the room back.
The bottom of the arc is masked with a gradient, so the cards that run off the bottom edge dissolve rather than ending on a straight cut. It is on the arc alone — the copy underneath is never faded — and it is switched off in the narrow layout, where nothing is cut off at the bottom to begin with.
The height of the section comes from the arc, not the copy: it is the radius plus half a card plus a margin. Long copy pushes past that and the section grows; the pivot stays pinned to the bottom edge, so the arc travels with it.
Below 640px of section width the arc moves to the top of the section and the copy runs underneath it, with the radius widened so the ends swing off the sides rather than into the text. The feature row stacks into one column there, with rules between the items instead of beside them.









