Component library
Back to catalog

Split image CTA

A call to action laid out as one rounded card split between copy and a full-bleed image.

ctaconversionmediacard

Open preview in a new tab

Use this component

Three ways to get Split image CTA into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 2 files appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Split image CTA from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/split-image-cta/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

The 2 files this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Split image CTA"
Library page: https://astro.baysixmedia.com/components/split-image-cta/
Files: 2 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/SplitImageCta.astro =====
---
/**
 * SplitImageCta — a call to action laid out as one rounded card split between
 * copy and a full-bleed image.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no script: drop it into any Astro project and render it.
 *
 * The breakpoints are **container queries**, not viewport ones, so the card
 * decides its own layout from the width it is actually given. Dropped into a
 * narrow sidebar it stacks; given a full-width page it splits — without the
 * host having to tell it which.
 *
 * Measured behaviour:
 *   card        18px radius, hairline border, muted ground, overflow clipped
 *   split       two equal columns at a container width of 672px and up
 *   stacking    below that the image goes on top and the copy beneath it
 *   copy pad    24px stacked, 40px split, 64px from 768px up
 *   cta         44px tall pill, recolours on hover, ring on focus
 */

type HeadingLevel = "h1" | "h2" | "h3";

interface Props {
	/** Small pill above the heading. Empty string hides it. */
	eyebrow?: string;
	/** The offer. Empty string hides it; with no description either, nothing renders. */
	heading?: string;
	/** Paragraph under the heading. Empty string hides it. */
	description?: string;
	/** Text on the button. Empty string hides the button entirely. */
	ctaLabel?: string;
	/**
	 * Where the button goes. Without it the label renders as plain text
	 * instead of a button that looks live and does nothing.
	 */
	ctaHref?: string;
	/** Open the call to action in a new tab, with the matching rel. */
	ctaExternal?: boolean;
	/** Image filling the other half. Omit for the drawn placeholder. */
	image?: string;
	/**
	 * Alternative text. Leave it out — or pass "" — for an image that carries
	 * no information the copy does not already give, which is the usual case
	 * for a decorative CTA illustration.
	 */
	imageAlt?: string;
	/** `object-position` for the image, for choosing what survives the crop. */
	imagePosition?: string;
	/** Heading level, for pages where h2 is wrong. */
	headingLevel?: HeadingLevel;
	/** Put the image on the left and the copy on the right, when split. */
	reverse?: boolean;
	/** Card background. */
	background?: string;
	/** Heading and pill colour, and the button's ground. */
	accent?: string;
	/** Body text colour. The muted tone is derived from it. */
	ink?: string;
	/** Corner radius of the card. */
	radius?: string;
	/** Caps the card width and centres it. */
	maxWidth?: string;
	class?: string;
	id?: string;
}

const {
	/* No sample copy: the offer, its wording and the button are the caller's. */
	eyebrow = "",
	heading = "",
	description = "",
	ctaLabel = "",
	/*
	 * No default destination on purpose. A placeholder like "#catalog" would
	 * render a real <a> pointing at an anchor that does not exist in the host
	 * page — exactly the dead link the guard below is meant to prevent. Left
	 * undefined, a caller who forgets to pass one gets the visibly inert
	 * label instead of a button that silently goes nowhere.
	 */
	ctaHref,
	ctaExternal = false,
	image,
	imageAlt = "",
	imagePosition = "center",
	headingLevel = "h2",
	reverse = false,
	background = "#f4f4f7",
	accent = "#4a35c4",
	ink = "#16161d",
	radius = "18px",
	maxWidth = "80rem",
	class: className,
	id,
} = Astro.props;

const Heading: HeadingLevel = headingLevel;

/*
 * A destination only counts if it is a non-empty string. Anything else — an
 * omitted prop, an empty string, a stray "#" — renders the label as text, so
 * nothing here can look like a button while doing nothing.
 */
const trimmed = ctaHref?.trim();
const hasDestination = !!trimmed && trimmed !== "#";

/* A card with no heading and no description has nothing to offer: render nothing. */
const hasContent = !!heading || !!description;
---

{hasContent && (
<section
	class:list={["cta", className]}
	id={id}
	data-split-cta
	data-reverse={reverse ? "" : undefined}
	style={`--sp-bg:${background};--sp-accent:${accent};--sp-ink:${ink};--sp-radius:${radius};--sp-max:${maxWidth};--sp-image-pos:${imagePosition};`}
>
	<div class="card">
		<div class="grid">
			<div class="copy">
				{eyebrow && <p class="eyebrow">{eyebrow}</p>}

				{heading && <Heading class="heading">{heading}</Heading>}

				{description && <p class="description">{description}</p>}

				{
					ctaLabel &&
						(hasDestination ? (
							<p class="actions">
								<a
									class="button"
									href={ctaHref}
									target={ctaExternal ? "_blank" : undefined}
									rel={ctaExternal ? "noopener noreferrer" : undefined}
								>
									{ctaLabel}
								</a>
							</p>
						) : (
							<p class="actions">
								<span class="button button-static">{ctaLabel}</span>
							</p>
						))
				}
			</div>

			<div class="media">
				{
					image ? (
						<img
							class="shot"
							src={image}
							alt={imageAlt}
							loading="lazy"
							decoding="async"
						/>
					) : (
						<div class="placeholder" aria-hidden="true" />
					)
				}
			</div>
		</div>
	</div>
</section>
)}

<style>
	.cta {
		--sp-muted: color-mix(in srgb, var(--sp-ink) 62%, transparent);
		--sp-line: color-mix(in srgb, var(--sp-ink) 12%, transparent);
		--sp-pill-bg: color-mix(in srgb, var(--sp-accent) 12%, transparent);

		--sp-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		box-sizing: border-box;
		display: block;
		padding: clamp(48px, 7vw, 96px) 16px;
		font-family: var(--sp-font);
		/* The card sizes itself from this box, not from the viewport. */
		container-type: inline-size;
	}

	.cta :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.card {
		max-width: var(--sp-max);
		margin-inline: auto;
		overflow: hidden;
		border: 1px solid var(--sp-line);
		border-radius: var(--sp-radius);
		background: var(--sp-bg);
		color: var(--sp-ink);
	}

	.grid {
		display: grid;
		grid-template-columns: 1fr;
	}

	/*
	 * Stacked, the image leads: it is the thing that identifies the offer at a
	 * glance, and a reader on a phone meets it before the paragraph.
	 */
	.copy {
		order: 2;
		padding: 24px;
	}

	.media {
		order: 1;
		position: relative;
		min-height: 12rem;
	}

	/* ---------- copy ---------- */

	.eyebrow {
		display: inline-block;
		margin: 0 0 16px;
		padding: 6px 16px;
		border-radius: 999px;
		background: var(--sp-pill-bg);
		color: var(--sp-accent);
		font-size: 0.875rem;
		font-weight: 500;
		line-height: 1.43;
	}

	.heading {
		margin: 0 0 16px;
		font-size: 1.875rem;
		line-height: 1.1;
		font-weight: 600;
		letter-spacing: -0.025em;
		text-wrap: balance;
	}

	.description {
		margin: 0 0 32px;
		max-width: 46ch;
		font-size: 1rem;
		line-height: 1.6;
		color: var(--sp-muted);
		text-wrap: pretty;
	}

	.actions {
		margin: 0;
	}

	.button {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		min-height: 44px;
		padding: 0 20px;
		border-radius: 8px;
		background: var(--sp-accent);
		color: #fff;
		font-size: 1rem;
		font-weight: 500;
		line-height: 1.5;
		text-decoration: none;
		transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	/*
	 * The label with no destination. It is a <span>, so it is already out of
	 * the tab order and unclickable; this makes that legible rather than
	 * leaving something that looks like a working button.
	 */
	.button-static {
		background: color-mix(in srgb, var(--sp-ink) 12%, transparent);
		color: var(--sp-muted);
		cursor: default;
		transition: none;
	}

	.button:focus-visible {
		outline: 3px solid var(--sp-accent);
		outline-offset: 3px;
	}

	/* ---------- media ---------- */

	.shot {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
		object-position: var(--sp-image-pos);
	}

	/* Drawn stand-in, so the component has nothing to download by default. */
	.placeholder {
		width: 100%;
		height: 100%;
		min-height: 12rem;
		background:
			radial-gradient(
				ellipse 70% 60% at 30% 30%,
				color-mix(in srgb, var(--sp-accent) 32%, transparent) 0%,
				transparent 70%
			),
			repeating-linear-gradient(
				135deg,
				color-mix(in srgb, var(--sp-ink) 5%, transparent) 0 12px,
				transparent 12px 24px
			),
			color-mix(in srgb, var(--sp-ink) 8%, var(--sp-bg));
	}

	/* ---------- container breakpoints ---------- */

	@container (min-width: 672px) {
		.grid {
			grid-template-columns: 1fr 1fr;
			align-items: stretch;
		}

		/* Split, the copy leads and the image sits beside it. */
		.copy {
			order: 1;
			align-self: center;
			padding: 40px;
		}

		.media {
			order: 2;
			min-height: 0;
		}

		/* `reverse` only means anything once there are two columns to swap. */
		.cta[data-reverse] .copy {
			order: 2;
		}

		.cta[data-reverse] .media {
			order: 1;
		}

		.heading {
			font-size: 2.25rem;
		}
	}

	@container (min-width: 768px) {
		.copy {
			padding: 64px;
		}
	}

	@container (min-width: 896px) {
		.heading {
			font-size: 3rem;
			line-height: 1;
		}

		.description {
			font-size: 1.125rem;
		}
	}

	/* Hover is a pointer affordance. On a touch screen :hover sticks after the
	   finger lifts, which would strand the button in its hover colour. */
	@media (hover: hover) and (pointer: fine) {
		a.button:hover {
			background: color-mix(in srgb, var(--sp-accent) 88%, #fff);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.button {
			transition: none;
		}
	}
</style>

===== END src/components/SplitImageCta.astro =====

===== FILE (supporting): src/components/SplitImageCta.md =====
# Split image CTA

A call to action laid out as one rounded card split between copy and a
full-bleed image. Two equal columns when there is room, stacked with the image
on top when there is not.

Catalog page: `/components/split-image-cta`

## Copying it into another Astro project

Copy **`SplitImageCta.astro`**. That is the whole component.

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--sp-*` property on the root |
| Webfonts | none — system stack |
| **JavaScript** | **none** — every state is CSS |
| Bundled images | **none in the component** — the demo ships one; see below |
| Network requests | none, beyond the image you pass |
| Host requirement | Astro compiles `.astro` files |

### Exact steps

1. Copy `SplitImageCta.astro` into your components folder, e.g.
   `src/components/SplitImageCta.astro`.
2. Render it, passing your own copy and image:

```astro
---
import SplitImageCta from "../components/SplitImageCta.astro";
---

<SplitImageCta
  eyebrow="Start free"
  heading="Ship the next page without rebuilding the last one"
  description="One or two sentences on why it is worth doing now."
  ctaLabel="Browse the catalog"
  ctaHref="/catalog"
  image="/images/product.webp"
  imageAlt=""
/>
```

3. There is no step three. With no `image` the component draws a placeholder
   panel rather than a broken box, so you can wire the art up later.

The section paints its own padding and centres the card, so give it the full
page width rather than nesting it in an already-padded container.

## The demo's image dependency

The catalog demo passes one image, so copying it as-you-see-it needs this file
as well as the component:

```
public/split-image-cta/workspace-1200.webp   (17 KB, 1200 × 900)
```

Nothing in the `.astro` file imports it — it is passed by URL — so it will not
follow the component on its own. The catalog's source panel lists it under
**assets** for that reason, and the copy button includes it.

### Where the demo image came from

It is **original**, generated for this catalog — no stock photography, no third
party, nothing to credit.

An abstract composition of stacked interface panels was built as a single HTML
page of CSS gradients, rounded rectangles and box-shadows: a violet-to-rose
ground, three overlapping "cards" at slight rotations, and bars, a button and a
sparkline standing in for interface content. It was rendered headless at
1200 × 900 and saved as WebP.

To regenerate or restyle it, rebuild that page and screenshot it at your chosen
size. The composition is deliberately centred on a continuous ground, because
the image column stretches to whatever height the copy needs — see *How the
image is cropped*.

## Props

The copy props are empty by default — the component carries no sample offer.
**With no `heading` and no `description` it renders nothing**, so a stray
button can never appear on its own. The catalog demo passes its copy as props.

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string` | `""` | Pill above the heading. Empty hides it. |
| `heading` | `string` | `""` | The offer. Empty hides it. |
| `description` | `string` | `""` | Paragraph under the heading. Empty hides it. |
| `ctaLabel` | `string` | `""` | Button text. Empty hides the button entirely. |
| `ctaHref` | `string` | — | Destination. Omit and the label renders as plain text. |
| `ctaExternal` | `boolean` | `false` | Opens in a new tab with `rel="noopener noreferrer"`. |
| `image` | `string` | — | Image filling the other half. Omit for the placeholder. |
| `imageAlt` | `string` | `""` | Alternative text. Empty means decorative. |
| `imagePosition` | `string` | `"center"` | `object-position`, for choosing what survives the crop. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is wrong. |
| `reverse` | `boolean` | `false` | Image on the left, copy on the right — when split. |
| `background` | `string` | `"#f4f4f7"` | Card background. |
| `accent` | `string` | `"#4a35c4"` | Pill text, pill tint and button ground. |
| `ink` | `string` | `"#16161d"` | Text colour. The muted tone derives from it. |
| `radius` | `string` | `"18px"` | Corner radius of the card. |
| `maxWidth` | `string` | `"80rem"` | Caps the card width and centres it. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | — | Root element id. |

## Layout

The breakpoints are **container queries**, not viewport ones, so the card reads
the width it is actually given. Drop it in a narrow sidebar and it stacks;
give it a full-width page and it splits — the host never has to say which.

| Container width | Layout | Copy padding | Heading | Description |
| :--- | :--- | :--- | :--- | :--- |
| < 672px | stacked, **image on top** | 24px | 30px | 16px |
| ≥ 672px | two equal columns | 40px | 36px | 16px |
| ≥ 768px | two equal columns | 64px | 36px | 16px |
| ≥ 896px | two equal columns | 64px | 48px | 18px |

The columns are always **1:1**. The image is `object-fit: cover` with no radius
of its own — the card's `overflow: hidden` is what rounds its outer corners, so
the image meets the card edge exactly.

`reverse` only does anything once there are two columns; stacked, the image
leads either way, because on a phone it is the thing that identifies the offer
before the paragraph does.

### How the image is cropped

The image column stretches to whatever height the copy needs, so the aspect
ratio it is cropped to changes with the copy length and the container width —
roughly 1.2:1 at desktop, taller and narrower at tablet, 4:3 when stacked. Use
`imagePosition` to choose what survives, and prefer art whose subject is near
the centre.

## Accessibility

- **The call to action is a real `<a>` when it has a destination**, so it is
  keyboard-operable, right-clickable and announced as a link.
- **A missing destination never produces a dead link.** Omit `ctaHref` — or
  pass `""` or `"#"` — and the label renders as a `<span>` instead: dimmed,
  default cursor, out of the tab order. Nothing here can look clickable while
  doing nothing.
- **Focus is explicit**: a 3px outline in the accent at a 3px offset on
  `:focus-visible`.
- **Decorative by default.** `imageAlt` defaults to `""`, which is right for a
  CTA illustration that repeats what the heading already says. Pass real alt
  text when the image carries information the copy does not.
- **`headingLevel`** keeps the document outline correct — a CTA near the top of
  a page may want `h1`, one in the middle `h2` or `h3`.
- **Contrast**, measured on the defaults with alpha composited against the card:

  | | Ratio | Required |
  | :--- | :--- | :--- |
  | Heading, 48px 600 | 16.4:1 | 3:1 |
  | Description, 18px | 6.0:1 | 4.5:1 |
  | Eyebrow on its pill, 14px | 5.6:1 | 4.5:1 |
  | Button text on the button, 16px | 8.1:1 | 4.5:1 |

  If you change `accent`, re-check the button and the pill — a lighter accent
  will fail where this one passes.
- **Reduced motion:** `prefers-reduced-motion: reduce` removes the button's
  colour transition. Nothing else moves, so nothing else needs disabling.
- **No horizontal overflow** at any width down to 320px.

## Customisation

Everything visual is a custom property on the root, so a wrapper can restyle it
without touching the file:

```css
.my-cta {
  --sp-accent: #0f766e;
  --sp-bg: #ecfdf5;
  --sp-ink: #052e25;
  --sp-radius: 4px;
}
```

Pass `class="my-cta"` to attach it. `accent`, `background`, `ink`, `radius` and
`maxWidth` are also plain props if you would rather set them inline.

**Multiple instances are independent** — there is no script and no shared
state, so each one takes its own props. Verified with four on one page:
different accents, backgrounds, heading levels, reversed and not.

## Notes on the reference

The layout — a muted rounded card split between copy and a full-bleed image, a
pill eyebrow, a large tight heading and a solid button — was studied from a
live component gallery. **None of that component's source, wording, image or
branding came with it.** The markup, styles, copy, placeholder and demo image
are written or generated here.

Two deliberate differences:

- **The reference has no `reverse`**; the image is always on the right.
- **The reference's section padding is a fixed 96px**; this uses
  `clamp(48px, 7vw, 96px)` so a narrow container does not get 96px of air above
  and below a stacked card.

===== END src/components/SplitImageCta.md =====

===== FILE (required): public/split-image-cta/workspace-1200.webp =====
[binary asset — download https://astro.baysixmedia.com/split-image-cta/workspace-1200.webp and save it to public/split-image-cta/workspace-1200.webp]

--- VERIFICATION ---
This component is not enrolled in the library's verification system, so there is no
recorded evidence about it either way. Treat it as unverified and test it yourself.

--- TEST IN THIS PROJECT AFTER INSTALLING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Split image CTA” from my Astro Component Library:
https://astro.baysixmedia.com/components/split-image-cta/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

src/components/library/SplitImageCta.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/split-image-cta/workspace-1200.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * SplitImageCta — a call to action laid out as one rounded card split between
 * copy and a full-bleed image.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no script: drop it into any Astro project and render it.
 *
 * The breakpoints are **container queries**, not viewport ones, so the card
 * decides its own layout from the width it is actually given. Dropped into a
 * narrow sidebar it stacks; given a full-width page it splits — without the
 * host having to tell it which.
 *
 * Measured behaviour:
 *   card        18px radius, hairline border, muted ground, overflow clipped
 *   split       two equal columns at a container width of 672px and up
 *   stacking    below that the image goes on top and the copy beneath it
 *   copy pad    24px stacked, 40px split, 64px from 768px up
 *   cta         44px tall pill, recolours on hover, ring on focus
 */

type HeadingLevel = "h1" | "h2" | "h3";

interface Props {
	/** Small pill above the heading. Empty string hides it. */
	eyebrow?: string;
	/** The offer. Empty string hides it; with no description either, nothing renders. */
	heading?: string;
	/** Paragraph under the heading. Empty string hides it. */
	description?: string;
	/** Text on the button. Empty string hides the button entirely. */
	ctaLabel?: string;
	/**
	 * Where the button goes. Without it the label renders as plain text
	 * instead of a button that looks live and does nothing.
	 */
	ctaHref?: string;
	/** Open the call to action in a new tab, with the matching rel. */
	ctaExternal?: boolean;
	/** Image filling the other half. Omit for the drawn placeholder. */
	image?: string;
	/**
	 * Alternative text. Leave it out — or pass "" — for an image that carries
	 * no information the copy does not already give, which is the usual case
	 * for a decorative CTA illustration.
	 */
	imageAlt?: string;
	/** `object-position` for the image, for choosing what survives the crop. */
	imagePosition?: string;
	/** Heading level, for pages where h2 is wrong. */
	headingLevel?: HeadingLevel;
	/** Put the image on the left and the copy on the right, when split. */
	reverse?: boolean;
	/** Card background. */
	background?: string;
	/** Heading and pill colour, and the button's ground. */
	accent?: string;
	/** Body text colour. The muted tone is derived from it. */
	ink?: string;
	/** Corner radius of the card. */
	radius?: string;
	/** Caps the card width and centres it. */
	maxWidth?: string;
	class?: string;
	id?: string;
}

const {
	/* No sample copy: the offer, its wording and the button are the caller's. */
	eyebrow = "",
	heading = "",
	description = "",
	ctaLabel = "",
	/*
	 * No default destination on purpose. A placeholder like "#catalog" would
	 * render a real <a> pointing at an anchor that does not exist in the host
	 * page — exactly the dead link the guard below is meant to prevent. Left
	 * undefined, a caller who forgets to pass one gets the visibly inert
	 * label instead of a button that silently goes nowhere.
	 */
	ctaHref,
	ctaExternal = false,
	image,
	imageAlt = "",
	imagePosition = "center",
	headingLevel = "h2",
	reverse = false,
	background = "#f4f4f7",
	accent = "#4a35c4",
	ink = "#16161d",
	radius = "18px",
	maxWidth = "80rem",
	class: className,
	id,
} = Astro.props;

const Heading: HeadingLevel = headingLevel;

/*
 * A destination only counts if it is a non-empty string. Anything else — an
 * omitted prop, an empty string, a stray "#" — renders the label as text, so
 * nothing here can look like a button while doing nothing.
 */
const trimmed = ctaHref?.trim();
const hasDestination = !!trimmed && trimmed !== "#";

/* A card with no heading and no description has nothing to offer: render nothing. */
const hasContent = !!heading || !!description;
---

{hasContent && (
<section
	class:list={["cta", className]}
	id={id}
	data-split-cta
	data-reverse={reverse ? "" : undefined}
	style={`--sp-bg:${background};--sp-accent:${accent};--sp-ink:${ink};--sp-radius:${radius};--sp-max:${maxWidth};--sp-image-pos:${imagePosition};`}
>
	<div class="card">
		<div class="grid">
			<div class="copy">
				{eyebrow && <p class="eyebrow">{eyebrow}</p>}

				{heading && <Heading class="heading">{heading}</Heading>}

				{description && <p class="description">{description}</p>}

				{
					ctaLabel &&
						(hasDestination ? (
							<p class="actions">
								<a
									class="button"
									href={ctaHref}
									target={ctaExternal ? "_blank" : undefined}
									rel={ctaExternal ? "noopener noreferrer" : undefined}
								>
									{ctaLabel}
								</a>
							</p>
						) : (
							<p class="actions">
								<span class="button button-static">{ctaLabel}</span>
							</p>
						))
				}
			</div>

			<div class="media">
				{
					image ? (
						<img
							class="shot"
							src={image}
							alt={imageAlt}
							loading="lazy"
							decoding="async"
						/>
					) : (
						<div class="placeholder" aria-hidden="true" />
					)
				}
			</div>
		</div>
	</div>
</section>
)}

<style>
	.cta {
		--sp-muted: color-mix(in srgb, var(--sp-ink) 62%, transparent);
		--sp-line: color-mix(in srgb, var(--sp-ink) 12%, transparent);
		--sp-pill-bg: color-mix(in srgb, var(--sp-accent) 12%, transparent);

		--sp-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		box-sizing: border-box;
		display: block;
		padding: clamp(48px, 7vw, 96px) 16px;
		font-family: var(--sp-font);
		/* The card sizes itself from this box, not from the viewport. */
		container-type: inline-size;
	}

	.cta :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.card {
		max-width: var(--sp-max);
		margin-inline: auto;
		overflow: hidden;
		border: 1px solid var(--sp-line);
		border-radius: var(--sp-radius);
		background: var(--sp-bg);
		color: var(--sp-ink);
	}

	.grid {
		display: grid;
		grid-template-columns: 1fr;
	}

	/*
	 * Stacked, the image leads: it is the thing that identifies the offer at a
	 * glance, and a reader on a phone meets it before the paragraph.
	 */
	.copy {
		order: 2;
		padding: 24px;
	}

	.media {
		order: 1;
		position: relative;
		min-height: 12rem;
	}

	/* ---------- copy ---------- */

	.eyebrow {
		display: inline-block;
		margin: 0 0 16px;
		padding: 6px 16px;
		border-radius: 999px;
		background: var(--sp-pill-bg);
		color: var(--sp-accent);
		font-size: 0.875rem;
		font-weight: 500;
		line-height: 1.43;
	}

	.heading {
		margin: 0 0 16px;
		font-size: 1.875rem;
		line-height: 1.1;
		font-weight: 600;
		letter-spacing: -0.025em;
		text-wrap: balance;
	}

	.description {
		margin: 0 0 32px;
		max-width: 46ch;
		font-size: 1rem;
		line-height: 1.6;
		color: var(--sp-muted);
		text-wrap: pretty;
	}

	.actions {
		margin: 0;
	}

	.button {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		min-height: 44px;
		padding: 0 20px;
		border-radius: 8px;
		background: var(--sp-accent);
		color: #fff;
		font-size: 1rem;
		font-weight: 500;
		line-height: 1.5;
		text-decoration: none;
		transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	/*
	 * The label with no destination. It is a <span>, so it is already out of
	 * the tab order and unclickable; this makes that legible rather than
	 * leaving something that looks like a working button.
	 */
	.button-static {
		background: color-mix(in srgb, var(--sp-ink) 12%, transparent);
		color: var(--sp-muted);
		cursor: default;
		transition: none;
	}

	.button:focus-visible {
		outline: 3px solid var(--sp-accent);
		outline-offset: 3px;
	}

	/* ---------- media ---------- */

	.shot {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
		object-position: var(--sp-image-pos);
	}

	/* Drawn stand-in, so the component has nothing to download by default. */
	.placeholder {
		width: 100%;
		height: 100%;
		min-height: 12rem;
		background:
			radial-gradient(
				ellipse 70% 60% at 30% 30%,
				color-mix(in srgb, var(--sp-accent) 32%, transparent) 0%,
				transparent 70%
			),
			repeating-linear-gradient(
				135deg,
				color-mix(in srgb, var(--sp-ink) 5%, transparent) 0 12px,
				transparent 12px 24px
			),
			color-mix(in srgb, var(--sp-ink) 8%, var(--sp-bg));
	}

	/* ---------- container breakpoints ---------- */

	@container (min-width: 672px) {
		.grid {
			grid-template-columns: 1fr 1fr;
			align-items: stretch;
		}

		/* Split, the copy leads and the image sits beside it. */
		.copy {
			order: 1;
			align-self: center;
			padding: 40px;
		}

		.media {
			order: 2;
			min-height: 0;
		}

		/* `reverse` only means anything once there are two columns to swap. */
		.cta[data-reverse] .copy {
			order: 2;
		}

		.cta[data-reverse] .media {
			order: 1;
		}

		.heading {
			font-size: 2.25rem;
		}
	}

	@container (min-width: 768px) {
		.copy {
			padding: 64px;
		}
	}

	@container (min-width: 896px) {
		.heading {
			font-size: 3rem;
			line-height: 1;
		}

		.description {
			font-size: 1.125rem;
		}
	}

	/* Hover is a pointer affordance. On a touch screen :hover sticks after the
	   finger lifts, which would strand the button in its hover colour. */
	@media (hover: hover) and (pointer: fine) {
		a.button:hover {
			background: color-mix(in srgb, var(--sp-accent) 88%, #fff);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.button {
			transition: none;
		}
	}
</style>