The 3 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: "Committed about split"
Library page: https://astro.baysixmedia.com/components/committed-about-split/
Files: 3 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/CommittedAboutSplit.astro =====
---
/**
* CommittedAboutSplit
*
* A two-column about section: a rounded photo on one side, and on the other an
* orange eyebrow with a short rule, a heading, a paragraph, three "»" points
* and an orange pill link. `mediaSide` puts the photo left or right.
*
* Give it a `videoUrl` and the photo becomes a video thumbnail: a navy wash,
* a round play button in the middle, and a modal <dialog> that plays the
* video (a YouTube/Vimeo embed in an iframe, or an .mp4/.webm file in a
* <video>). Without JavaScript the play button is a plain link to the video.
*
* On scroll the photo slides in from its outer side (40px) and the copy from
* the other side.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface CommittedAboutSplitLink {
label: string;
href: string;
}
export interface CommittedAboutSplitProps {
/** Which side the photo sits on at 810px and up. On a phone the DOM order holds. */
mediaSide?: "left" | "right";
/** The photo, or the video thumbnail. No `src` renders the labelled placeholder. */
image?: { src?: string; alt?: string };
/** Turns the photo into a video thumbnail with a play button and a modal player. */
videoUrl?: string;
/** Accessible name for the play button and the player. Omitted: "Play video" / "Video". */
videoTitle?: string;
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
text?: string;
/** The "»" points. `[]` drops the list. */
points?: string[];
/** The orange pill. `null` (the default) drops it. */
cta?: CommittedAboutSplitLink | null;
/** Section surface. The home variant is #ecf0f1, the about variant white. */
background?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends CommittedAboutSplitProps {}
/*
* The heading, copy, points, button, photo and video title are the caller's:
* nothing about a business is filled in here. "About us" is a generic label
* and never renders the section on its own. The catalog demo passes its
* sample content through the registry's `props`.
*/
const {
mediaSide = "left",
image: imageProp,
videoUrl,
videoTitle = "",
eyebrow = "About us",
heading = "",
headingLevel = "h2",
text = "",
points = [],
cta = null,
background = "#ecf0f1",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
const hasVideo = Boolean(videoUrl && videoUrl.trim());
/* A photo given without alt is decorative. No photo: the neutral placeholder. */
const image = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };
const pointList = (points ?? []).filter((p) => typeof p === "string" && p.trim());
const title = videoTitle.trim();
const playLabel = title ? `Play video: ${title}` : "Play video";
/* Copy worth a column; the generic eyebrow alone is not. */
const hasCopy = Boolean(heading || text || pointList.length > 0 || cta);
/* Nothing to say → nothing rendered. */
const hasContent = hasCopy || hasVideo;
const Heading = headingLevel;
const uid = id ?? `cas-${Math.random().toString(36).slice(2, 8)}`;
const dialogId = `${uid}-player`;
const titleId = `${uid}-player-title`;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const video = hasVideo ? safeHref(videoUrl!) : "";
const isFile = /\.(mp4|webm|ogg)(\?|#|$)/i.test(video);
const IMAGE_SPEC = hasVideo ? "1:1 · 1128 × 1124 px" : "~0.95:1 · 1152 × 1214 px";
const IMAGE_HINT = hasVideo ? "Video thumbnail, square" : "Photo, near-square portrait";
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section
class:list={["cas", className]}
id={id}
style={`--cas-bg: ${background};`}
data-cas-root
data-media-side={mediaSide}
data-video={hasVideo ? "" : undefined}
>
<div class="cas-inner">
{
mediaSide === "right" && hasCopy && (
<div class="cas-copy" data-cas-copy>
{eyebrow && (
<p class="cas-eyebrow">
{eyebrow}
<span class="cas-rule" aria-hidden="true" />
</p>
)}
{heading && <Heading class="cas-heading">{heading}</Heading>}
{text && <p class="cas-text">{text}</p>}
{pointList.length > 0 && (
<ul class="cas-points" role="list">
{pointList.map((point) => (
<li class="cas-point">
<svg class="cas-chev" viewBox="0 0 18 14" width="18" height="14" aria-hidden="true" focusable="false">
<path d="M2 1.5 7.5 7 2 12.5M9.5 1.5 15 7l-5.5 5.5" />
</svg>
<span>{point}</span>
</li>
))}
</ul>
)}
{cta && (
<a class="cas-btn" href={safeHref(cta.href)}>
{cta.label}
<svg class="cas-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
<path d="M2 10 10 2M3.5 2H10v6.5" />
</svg>
</a>
)}
</div>
)
}
<div class="cas-media" data-cas-media>
{
image.src ? (
<img
src={image.src}
alt={image.alt}
width={hasVideo ? 1128 : 1152}
height={hasVideo ? 1124 : 1214}
loading="lazy"
decoding="async"
/>
) : (
<div class="cas-ph" role="img" aria-label={`Image placeholder: ${IMAGE_HINT}`}>
<div class="cas-ph-text" aria-hidden="true">
<span class="cas-ph-label">Image</span>
<span class="cas-ph-hint">{IMAGE_HINT}</span>
<span class="cas-ph-spec">{IMAGE_SPEC}</span>
</div>
</div>
)
}
{
hasVideo && (
<>
<span class="cas-wash" aria-hidden="true" />
<a
class="cas-play"
href={video}
target="_blank"
rel="noopener"
aria-label={playLabel}
aria-haspopup="dialog"
data-cas-play
>
<span class="cas-play-halo" aria-hidden="true" />
<span class="cas-play-disc" aria-hidden="true">
<svg viewBox="0 0 12 14" width="12" height="14" focusable="false">
<path d="M1 1.2v11.6a.6.6 0 0 0 .9.5l9.6-5.8a.6.6 0 0 0 0-1L1.9.7a.6.6 0 0 0-.9.5Z" />
</svg>
</span>
</a>
</>
)
}
</div>
{
mediaSide !== "right" && hasCopy && (
<div class="cas-copy" data-cas-copy>
{eyebrow && (
<p class="cas-eyebrow">
{eyebrow}
<span class="cas-rule" aria-hidden="true" />
</p>
)}
{heading && <Heading class="cas-heading">{heading}</Heading>}
{text && <p class="cas-text">{text}</p>}
{pointList.length > 0 && (
<ul class="cas-points" role="list">
{pointList.map((point) => (
<li class="cas-point">
<svg class="cas-chev" viewBox="0 0 18 14" width="18" height="14" aria-hidden="true" focusable="false">
<path d="M2 1.5 7.5 7 2 12.5M9.5 1.5 15 7l-5.5 5.5" />
</svg>
<span>{point}</span>
</li>
))}
</ul>
)}
{cta && (
<a class="cas-btn" href={safeHref(cta.href)}>
{cta.label}
<svg class="cas-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
<path d="M2 10 10 2M3.5 2H10v6.5" />
</svg>
</a>
)}
</div>
)
}
</div>
{
hasVideo && (
<dialog class="cas-dialog" id={dialogId} aria-labelledby={titleId} data-cas-dialog data-src={video} data-kind={isFile ? "file" : "embed"}>
<div class="cas-dialog-bar">
<p class="cas-dialog-title" id={titleId}>{title || "Video"}</p>
<button type="button" class="cas-close" data-cas-close aria-label="Close video">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div>
<div class="cas-frame" data-cas-frame />
</dialog>
)
}
</section>
)}
<style>
.cas {
--cas-navy: var(--tg-navy, #020e28);
--cas-orange: var(--tg-orange, #fd5523);
--cas-muted: var(--tg-muted, #61697d);
--cas-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
--cas-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
--cas-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
--cas-radius: 30px;
box-sizing: border-box;
padding: 80px 20px;
background: var(--cas-bg, #ecf0f1);
color: var(--cas-navy);
overflow-x: clip;
}
.cas[data-video] {
--cas-radius: 20px;
}
.cas *,
.cas *::before,
.cas *::after {
box-sizing: border-box;
}
.cas-inner {
display: flex;
flex-direction: column;
gap: 30px;
max-width: 1253px;
margin: 0 auto;
}
/* ---- media ---- */
.cas-media {
position: relative;
width: 100%;
aspect-ratio: 350 / 278;
border-radius: var(--cas-radius);
overflow: hidden;
background: #dfe5e7;
}
.cas[data-video] .cas-media {
aspect-ratio: 1;
}
.cas-media img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cas-wash {
position: absolute;
inset: 0;
background: rgb(2 14 40 / 0.5);
}
.cas-play {
position: absolute;
top: 50%;
left: 50%;
display: grid;
place-items: center;
width: 64px;
height: 64px;
margin: -32px 0 0 -32px;
border-radius: 50%;
cursor: pointer;
}
.cas-play-halo {
position: absolute;
inset: 0;
border-radius: 50%;
background: #fff;
opacity: 0.3;
}
.cas-play-disc {
position: relative;
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding-left: 2px;
border-radius: 50%;
background: #fff;
transition: transform 0.3s var(--cas-ease);
}
.cas-play-disc svg {
fill: var(--cas-orange);
}
.cas-play:hover .cas-play-disc {
transform: scale(1.05);
}
.cas-play:focus-visible {
outline: 3px solid #fff;
outline-offset: 4px;
}
.cas-ph {
display: grid;
place-items: center;
width: 100%;
height: 100%;
padding: 16px;
text-align: center;
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgb(2 14 40 / 0.62);
}
.cas-ph-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cas-ph-label {
font: 600 12px/1 var(--cas-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.cas-ph-hint {
max-width: 28ch;
font: 500 14px/1.4 var(--cas-body);
}
.cas-ph-spec {
font: 400 12px/1.2 var(--cas-body);
}
/* ---- copy ---- */
.cas-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.cas .cas-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
margin: 0 0 16px;
font: 600 16px/1 var(--cas-display);
text-transform: uppercase;
color: var(--cas-orange);
}
.cas-rule {
display: none;
width: 40px;
height: 1px;
background: var(--cas-orange);
}
.cas-heading {
margin: 0;
font: 600 32px/1.2 var(--cas-display);
letter-spacing: normal;
text-transform: capitalize;
color: var(--cas-navy);
}
.cas .cas-text {
margin: 14px 0 0;
font: 400 16px/21.04px var(--cas-body);
color: var(--cas-muted);
}
.cas .cas-points {
display: flex;
flex-direction: column;
gap: 14px;
margin: 22px 0 0;
padding: 0;
list-style: none;
}
.cas-point {
display: flex;
align-items: flex-start;
gap: 12px;
font: 600 18px/26px var(--cas-display);
color: var(--cas-navy);
}
.cas-chev {
flex: none;
margin-top: 6px;
fill: none;
stroke: var(--cas-orange);
stroke-width: 2.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.cas .cas-btn {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 48px;
margin-top: 30px;
padding: 18px 24px;
border-radius: 100px;
background: var(--cas-orange);
font: 600 14px/12.46px var(--cas-body);
text-transform: capitalize;
color: #fff;
text-decoration: none;
transition: background-color 0.3s var(--cas-ease);
}
.cas-btn-arrow {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 0.3s var(--cas-ease);
}
.cas .cas-btn:hover {
background: var(--cas-navy);
}
.cas .cas-btn:hover .cas-btn-arrow {
transform: rotate(45deg);
}
.cas .cas-btn:focus-visible {
outline: 2px solid var(--cas-navy);
outline-offset: 3px;
}
/* ---- appear: media from its outer side, copy from the other ---- */
.cas-media,
.cas-copy {
transition:
opacity 0.8s var(--cas-ease),
transform 0.8s var(--cas-ease);
}
.cas[data-cas-ready]:not([data-in]) .cas-media,
.cas[data-cas-ready]:not([data-in]) .cas-copy {
opacity: 0;
}
.cas[data-cas-ready][data-media-side="left"]:not([data-in]) .cas-media,
.cas[data-cas-ready][data-media-side="right"]:not([data-in]) .cas-copy {
transform: translateX(-40px);
}
.cas[data-cas-ready][data-media-side="left"]:not([data-in]) .cas-copy,
.cas[data-cas-ready][data-media-side="right"]:not([data-in]) .cas-media {
transform: translateX(40px);
}
/* ---- dialog ---- */
.cas-dialog {
width: min(960px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
padding: 0;
border: 0;
border-radius: 16px;
background: var(--cas-navy);
color: #fff;
overflow: hidden;
}
.cas-dialog::backdrop {
background: rgb(2 14 40 / 0.8);
}
.cas-dialog-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 6px 6px 18px;
}
.cas .cas-dialog-title {
margin: 0;
font: 600 16px/1.3 var(--cas-display);
}
.cas-close {
display: grid;
place-items: center;
flex: none;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #fff;
cursor: pointer;
transition: background-color 0.3s var(--cas-ease);
}
.cas-close svg {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.cas-close:hover {
background: var(--cas-orange);
}
.cas-close:focus-visible {
outline: 2px solid var(--cas-orange);
outline-offset: 2px;
}
.cas-frame {
position: relative;
aspect-ratio: 16 / 9;
background: #000;
}
.cas-frame :global(iframe),
.cas-frame :global(video) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
/* ---- tablet and up: side by side ---- */
@media (min-width: 810px) {
.cas {
padding: 100px 30px;
}
.cas-inner {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 40px;
}
.cas-media {
flex: 0 0 46%;
aspect-ratio: 576 / 607;
}
.cas[data-video] .cas-media {
flex-basis: 45%;
aspect-ratio: 564 / 562;
}
.cas-copy {
flex: 0 1 619px;
}
.cas .cas-eyebrow {
margin-bottom: 24px;
font-size: 18px;
line-height: 12.6px;
}
.cas-rule {
display: block;
}
.cas-heading {
font-size: 40px;
}
.cas .cas-text {
margin-top: 40px;
}
.cas .cas-points {
gap: 16px;
margin-top: 40px;
}
.cas-point {
font-size: 20px;
line-height: 30px;
}
.cas-chev {
margin-top: 8px;
}
.cas .cas-btn {
min-height: 56px;
margin-top: 50px;
padding: 22px 30px;
}
}
/* ---- desktop: the measured 1253 row ---- */
@media (min-width: 1200px) {
.cas {
padding: 120px 20px;
}
/* Step 39.2: --tg-grid-max / --tg-grid-gutter put the row on a site grid at desktop; the fallbacks give the original 1253px row. */
.cas-inner {
--cas-grid-max: var(--tg-grid-max, 1293px);
--cas-grid-gutter: var(--tg-grid-gutter, 20px);
max-width: calc(var(--cas-grid-max) - 2 * var(--cas-grid-gutter));
}
.cas-heading {
font-size: 48px;
line-height: 57.6px;
}
}
@media (prefers-reduced-motion: reduce) {
.cas-media,
.cas-copy,
.cas-play-disc,
.cas-btn-arrow {
transition: none;
}
.cas .cas-btn:hover .cas-btn-arrow,
.cas-play:hover .cas-play-disc {
transform: none;
}
.cas[data-cas-ready]:not([data-in]) .cas-media,
.cas[data-cas-ready]:not([data-in]) .cas-copy {
opacity: 1;
transform: none;
}
}
@media (forced-colors: active) {
.cas .cas-btn,
.cas-play-disc {
border: 1px solid ButtonText;
}
}
</style>
<script>
/*
* Appear-on-scroll, plus the video player when the section has one.
* One IntersectionObserver per instance; every listener hangs off one
* AbortController, aborted on astro:before-swap. The player is a native
* modal <dialog>: Escape and the backdrop close it, focus goes back to the
* play button, and the iframe or <video> is created on open and removed on
* close so nothing keeps playing. Nothing moves focus or scrolls on load.
*/
function initCommittedAboutSplit(root: HTMLElement) {
if (root.dataset.casReady === "") return;
const controller = new AbortController();
const { signal } = controller;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
root.dataset.casReady = "";
if (reduced || !("IntersectionObserver" in window)) {
root.dataset.in = "";
} else {
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.dataset.in = "";
observer.disconnect();
}
},
{ threshold: 0.2 },
);
observer.observe(root);
signal.addEventListener("abort", () => observer.disconnect());
}
const play = root.querySelector<HTMLAnchorElement>("[data-cas-play]");
const dialog = root.querySelector<HTMLDialogElement>("[data-cas-dialog]");
const frame = dialog?.querySelector<HTMLElement>("[data-cas-frame]");
const close = dialog?.querySelector<HTMLButtonElement>("[data-cas-close]");
if (play && dialog && frame && typeof dialog.showModal === "function") {
const src = dialog.dataset.src ?? "";
const title = dialog.querySelector(".cas-dialog-title")?.textContent ?? "Video";
const mount = () => {
frame.replaceChildren();
if (dialog.dataset.kind === "file") {
const video = document.createElement("video");
video.src = src;
video.controls = true;
video.playsInline = true;
video.autoplay = !reduced;
video.setAttribute("aria-label", title);
frame.append(video);
} else {
const iframe = document.createElement("iframe");
const url = new URL(src, location.href);
if (!reduced) url.searchParams.set("autoplay", "1");
iframe.src = url.toString();
iframe.title = title;
iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
iframe.allowFullscreen = true;
frame.append(iframe);
}
};
play.addEventListener(
"click",
(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
event.preventDefault();
mount();
dialog.showModal();
close?.focus();
},
{ signal },
);
close?.addEventListener("click", () => dialog.close(), { signal });
/* A click on the backdrop lands on the dialog element itself. */
dialog.addEventListener(
"click",
(event) => {
if (event.target === dialog) dialog.close();
},
{ signal },
);
dialog.addEventListener(
"close",
() => {
frame.replaceChildren();
play.focus();
},
{ signal },
);
signal.addEventListener("abort", () => {
if (dialog.open) dialog.close();
frame.replaceChildren();
});
}
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.casReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-cas-root]").forEach(initCommittedAboutSplit);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/CommittedAboutSplit.astro =====
===== FILE (supporting): src/components/CommittedAboutSplit.md =====
# Committed about split
A two-column about section: a rounded photo on one side; on the other an
orange eyebrow with a short rule, a heading, a paragraph, three "»" points
and an orange pill link. One component covers both of the reference's
versions:
- **Home:** photo left, grey `#ecf0f1` band.
- **About:** copy left, and on the right a video thumbnail with a round play
button that opens the video in a modal player, on white.
Catalog page: `/components/committed-about-split`
## Files required
**`CommittedAboutSplit.astro`** — the whole component and its one script.
No imports, no npm packages.
`CommittedAboutSplit.md` — this file.
`public/committed-about-split/port-dock.webp` and `port-aerial.webp` — the
catalog demo's photo and video thumbnail, passed as `image`. There is no
default photo.
## Basic usage
```astro
---
import CommittedAboutSplit from "../components/CommittedAboutSplit.astro";
---
<!-- home -->
<CommittedAboutSplit
heading="Freight partners who keep your business moving"
text="Since 1998 Northpass has planned, booked and tracked freight for companies that can't afford a late load."
points={["Dispatchers who answer the phone", "Inspected equipment, every trip"]}
cta={{ label: "About More", href: "/about" }}
image={{ src: "/committed-about-split/port-dock.webp", alt: "Stacked shipping containers on a quay beside a moored cargo ship" }}
/>
<!-- about -->
<CommittedAboutSplit
mediaSide="right"
background="#ffffff"
heading="Freight partners who keep your business moving"
image={{ src: "/committed-about-split/port-aerial.webp", alt: "" }}
videoUrl="https://www.youtube-nocookie.com/embed/VIDEO_ID"
videoTitle="A day at our Oakland cross-dock"
/>
```
Every value above is demo content. Nothing about a business is a default:
with no heading, text, points, button or video the component renders
nothing, and the "About us" eyebrow never renders on its own.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `mediaSide` | `"left" \| "right"?` | `"left"` | Photo side from 810px. Also sets DOM order, so on a phone the photo comes first for `left` and last for `right`, as on the reference. |
| `image` | `{ src?, alt? }?` | none | No `src` renders a labelled placeholder with a neutral hint. A `src` without `alt` is decorative. |
| `videoUrl` | `string?` | — | An embed URL (iframe) or an `.mp4`/`.webm`/`.ogg` file (`<video>`). Turns the photo into a video thumbnail. |
| `videoTitle` | `string?` | `""` | Play button name ("Play video: …"), dialog title, iframe title. Omitted: "Play video" and "Video". |
| `eyebrow` | `string?` | `"About us"` | Generic label. `""` drops it. Shown only when there is other copy. |
| `heading` | `string?` | `""` | Title-cased by CSS, as on the reference. Hidden when empty. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `text` | `string?` | `""` | Hidden when empty. |
| `points` | `string[]?` | `[]` | Empty drops the list. |
| `cta` | `{ label, href } \| null?` | `null` | `null` drops it. |
| `background` | `string?` | `#ecf0f1` | Section surface. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` skips it. |
| `class`, `id` | `string?` | — | `id` also seeds the dialog ids. |
## Behaviour
- **Appear** (measured): the media slides in from its outer side
(`translateX(-40px)` when left, `+40px` when right) and the copy from the
other side, both from `opacity 0`, 0.8s, once, at 20% in view.
- **Pill hover** (measured): bg `#fd5523` → `#020e28`, the ↗ arrow rotates
45°, 0.3s.
- **Play button** (measured): a 64px white halo at 30% with a 40px white disc
and an orange triangle, over a 50% navy wash. On hover the disc scales to
1.05.
- **Video player:** clicking the play button opens a native modal
`<dialog>`, creates the iframe (with `autoplay=1`, unless reduced motion)
or `<video>`, and moves focus to the close button. Escape, the close
button and a click on the backdrop close it; the player element is removed
so playback stops, and focus returns to the play button. Ctrl/⌘/Shift
click keeps the link's normal behaviour.
- **Without JS:** the play button is a link that opens the video URL in a
new tab; everything else is at rest.
- **Reduced motion:** no slides, no hover transforms, no autoplay.
- Lifecycle: one IntersectionObserver and all listeners on one
AbortController per instance, aborted on `astro:before-swap` (which also
closes an open dialog). Initialises on load and `astro:page-load`,
idempotently. Never scrolls or moves focus on load.
## Layout
| Width | Layout |
| :--- | :--- |
| ≥ 1200 | Photo 46% (576×607 in a 1253 row, radius 30) or video 45% (564×562, radius 20); copy up to 619px; 48/57.6 heading; 120px padding. |
| 810–1199 | Same split, 40px heading, 100px padding. |
| < 810 | Stacked in DOM order, 30px gap; photo 350:278, video square; 32px heading; eyebrow rule hidden; 80px padding. |
## Accessibility
- The "»" icons are decorative SVG; the points are a real list.
- The play button is an `<a>` with `aria-haspopup="dialog"` and a name that
says what it plays. The dialog is labelled by its visible title.
- Focus rings on the pill (navy), play button (white) and close button
(orange). Close button is 44px.
## Images and credits
| File | Size | Unsplash id |
| :--- | :--- | :--- |
| `public/committed-about-split/port-dock.webp` | 1152×1214 | MHXJ9p64Jw8 |
| `public/committed-about-split/port-aerial.webp` | 1128×1124 | IVG8SDczupk |
Unsplash License (free commercial use, no attribution required).
The demo `videoUrl` in the catalog is Blender's *Big Buck Bunny* on YouTube
(`aqz-KE-bpKQ`, checked via YouTube's oEmbed) — a stand-in only.
## Approximations
- The reference's video player wasn't captured (only the thumbnail and the
1.05 hover), so the dialog's look — navy frame, title bar, 16:9 — is this
library's design.
- Phone sizes (32px heading, 350:278 photo) are read off the 390 screenshot;
tablet was not captured.
- The reference's radius differs between the two variants (30px photo, 20px
video); that is kept, keyed off whether `videoUrl` is set.
===== END src/components/CommittedAboutSplit.md =====
===== FILE (required): public/committed-about-split/port-dock.webp =====
[binary asset — download https://astro.baysixmedia.com/committed-about-split/port-dock.webp and save it to public/committed-about-split/port-dock.webp]
===== FILE (required): public/committed-about-split/port-aerial.webp =====
[binary asset — download https://astro.baysixmedia.com/committed-about-split/port-aerial.webp and save it to public/committed-about-split/port-aerial.webp]
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.
--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- 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.