The 1 file 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: "Service showcase"
Library page: https://astro.baysixmedia.com/components/service-showcase/
Files: 1 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/ServiceShowcase.astro =====
---
/**
* ServiceShowcase — a display heading over alternating service rows, each
* pairing a window-framed media panel with a description, an exclusive
* accordion of sub-items, and a call to action.
*
* One self-contained file. No imports, no global stylesheet, no npm packages,
* and no bundled assets: drop it into any Astro project and render it.
*
* The media panel is drawn as a small application window — a chrome bar with
* three dots over a 16:9 stage. With no media supplied the stage paints a CSS
* placeholder, so the component has nothing to download and still looks
* deliberate; pass an image or a video per item to fill it.
*
* Measured behaviour:
* rows media/text side alternates at >=768, stacks media-first below
* proportions media 3fr / text 2fr at >=768
* window 16px radius, 1px border, 29px chrome bar, three 14px dots
* accordion grid-template-rows 0fr -> 1fr over 300ms ease-out,
* exclusive within its own list, chevron turns 180deg
* cta pill and icon tile recolour together over 200ms linear,
* icon tile scales to 1.04
* heading rises 40px and fades in as the section arrives
*
* Several instances can share a page: element ids are seeded from a per
* instance uid, and every listener is bound per accordion list, so two
* showcases never open each other's rows.
*/
export interface ShowcaseDetail {
/** Used for this row's element ids. Falls back to its position. */
id?: string;
/** The accordion row's label. */
label: string;
/** Text revealed when the row opens. */
body?: string;
}
export interface ShowcaseMedia {
/** Image or video URL. Anything the host can serve. */
src: string;
/** Treat `src` as a looping video rather than an image. */
video?: boolean;
/** Still shown before a video plays, and under reduced motion. */
poster?: string;
/** Alternative text. Required for an image that carries meaning. */
alt?: string;
}
export interface ShowcaseCta {
label: string;
href: string;
/** Open in a new tab, with the matching rel. */
external?: boolean;
}
export interface ShowcaseItem {
/** Used for the row's element ids. Falls back to its position. */
id?: string;
/** The service name, set in the large display size. */
title: string;
/** Paragraph under the title. */
description?: string;
/** Accordion rows. Omit for a row with no expandable list. */
details?: ShowcaseDetail[];
/** Fills the window's stage. Omit for the CSS placeholder. */
media?: ShowcaseMedia;
/** Caption drawn on the placeholder when there is no media. */
placeholderLabel?: string;
/** Button under the list. Omit for a row with no button. */
cta?: ShowcaseCta;
}
interface Props {
/** The display heading over the rows. Empty string hides it. */
heading?: string;
/** Heading level, for pages where h2 is wrong. */
headingLevel?: "h1" | "h2" | "h3";
/** The service rows. */
items?: ShowcaseItem[];
/** Flip the media to the other side on every second row, at >=768. */
alternate?: boolean;
/** Open each row's first accordion item on load. */
openFirst?: boolean;
/** Section background. */
surface?: string;
/** Text colour. Muted tones and hairlines are derived from it. */
ink?: string;
/** Button and hover colour. */
accent?: string;
/** Deeper accent, used for the icon tile on hover. */
accentDeep?: string;
/** Soft coloured glows behind the rows. */
glow?: boolean;
/** Fine film grain over the section. */
grain?: boolean;
/** Fade and lift the heading in as the section arrives. */
reveal?: boolean;
class?: string;
id?: string;
}
const {
heading = "What we do",
headingLevel = "h2",
items = [],
alternate = true,
openFirst = false,
surface = "#0a0a0c",
ink = "#ffffff",
accent = "#4338ca",
accentDeep = "#312c9c",
glow = true,
grain = true,
reveal = true,
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
/* A row needs a title, and an accordion row a label; anything without one is
dropped rather than drawn half-empty. No rows, no section. */
const rows = items
.filter((item) => item?.title)
.map((item) => ({ ...item, details: item.details?.filter((detail) => detail?.label) }));
const uid = id ?? `svc-${Math.random().toString(36).slice(2, 9)}`;
const itemKey = (item: ShowcaseItem, i: number) => item.id ?? String(i + 1);
const detailKey = (detail: ShowcaseDetail, i: number) => detail.id ?? String(i + 1);
/*
* A self-drawn grain tile. feTurbulence gives a stable, tiny noise field with
* no asset to ship and no request to make.
*/
const grainUrl =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.42'/%3E%3C/svg%3E\")";
---
{rows.length > 0 && (
<section
class:list={["showcase", className]}
id={id}
data-showcase
data-uid={uid}
data-reveal={reveal ? "" : undefined}
data-glow={glow ? "" : undefined}
data-grain={grain ? "" : undefined}
data-alternate={alternate ? "" : undefined}
style={`--sv-surface:${surface};--sv-ink:${ink};--sv-accent:${accent};--sv-accent-deep:${accentDeep};--sv-grain:${grainUrl};`}
>
{
glow && (
<div class="glows" aria-hidden="true">
<span class="glow glow-a" />
<span class="glow glow-b" />
<span class="glow glow-c" />
</div>
)
}
<div class="inner">
{
heading && (
<Heading class="heading" data-r-lift>
{heading}
</Heading>
)
}
<div class="rows">
{
rows.map((item, i) => {
const key = itemKey(item, i);
return (
<article class="row" id={item.id} data-row>
<div class="media">
<div class="chrome" aria-hidden="true">
<span class="dot dot-1" />
<span class="dot dot-2" />
<span class="dot dot-3" />
</div>
<div class="stage">
{item.media ? (
item.media.video ? (
<video
class="fill"
muted
loop
playsinline
preload="none"
poster={item.media.poster}
aria-label={item.media.alt}
data-video
>
<source src={item.media.src} />
</video>
) : (
<img
class="fill"
src={item.media.src}
alt={item.media.alt ?? ""}
loading="lazy"
decoding="async"
/>
)
) : (
<span class="placeholder" aria-hidden="true">
{item.placeholderLabel ?? "16 : 9"}
</span>
)}
</div>
</div>
<div class="copy">
<div class="lede">
<h3 class="title">{item.title}</h3>
{item.description && <p class="description">{item.description}</p>}
</div>
{item.details && item.details.length > 0 && (
<ul class="details" data-details>
{item.details.map((detail, d) => {
const dk = `${uid}-${key}-${detailKey(detail, d)}`;
const open = openFirst && d === 0;
return (
<li class="detail" data-detail data-open={open ? "" : undefined}>
<button
type="button"
class="detail-button"
id={`${dk}-button`}
aria-controls={`${dk}-panel`}
aria-expanded={open ? "true" : "false"}
data-detail-button
>
<span class="detail-label">{detail.label}</span>
<svg
class="chevron"
viewBox="0 0 24 24"
width="21"
height="21"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<div
class="detail-panel"
id={`${dk}-panel`}
role="region"
aria-labelledby={`${dk}-button`}
data-detail-panel
>
<div class="detail-inner">
{detail.body && <p class="detail-body">{detail.body}</p>}
</div>
</div>
</li>
);
})}
</ul>
)}
{item.cta && (
<div class="cta-wrap">
<a
class="cta"
href={item.cta.href}
target={item.cta.external ? "_blank" : undefined}
rel={item.cta.external ? "noopener noreferrer" : undefined}
>
<span class="cta-icon" aria-hidden="true">
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</span>
<span class="cta-label">{item.cta.label}</span>
</a>
</div>
)}
</div>
</article>
);
})
}
</div>
</div>
</section>
)}
<style>
.showcase {
/* Every tone is derived from --sv-ink, so one override re-tints the
whole section instead of leaving half of it behind. */
--sv-muted: color-mix(in srgb, var(--sv-ink) 62%, transparent);
--sv-hairline: color-mix(in srgb, var(--sv-ink) 20%, transparent);
--sv-frame: color-mix(in srgb, var(--sv-ink) 28%, transparent);
--sv-chrome: color-mix(in srgb, var(--sv-ink) 8%, var(--sv-surface));
--sv-stage: color-mix(in srgb, var(--sv-ink) 4%, var(--sv-surface));
--sv-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
/* 46px at a 390px viewport, 140px from 1280px up. */
--sv-display: clamp(2.9rem, 11vw, 8.75rem);
--sv-title: clamp(2.5rem, 4.5vw, 3.5rem);
--sv-body: 1rem;
--sv-detail: 1.3125rem;
--sv-gutter: clamp(24px, 3.4vw, 48px);
--sv-maxw: 1440px;
--sv-radius: 16px;
box-sizing: border-box;
position: relative;
display: block;
isolation: isolate;
overflow: clip;
padding-block: clamp(72px, 9vw, 128px);
background: var(--sv-surface);
color: var(--sv-ink);
font-family: var(--sv-font);
}
.showcase :where(*, *::before, *::after) {
box-sizing: border-box;
}
/* Fine grain over the whole section, drawn rather than downloaded. */
.showcase[data-grain]::after {
content: "";
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
opacity: 0.055;
background-image: var(--sv-grain);
background-repeat: repeat;
}
/* ---------- glows ---------- */
.glows {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
overflow: clip;
}
.glow {
position: absolute;
display: block;
border-radius: 50%;
}
.glow-a {
width: 1250px;
height: 700px;
top: -8%;
left: -40%;
background: radial-gradient(
ellipse farthest-side at center,
var(--sv-accent) 0%,
transparent 100%
);
opacity: 0.16;
}
.glow-b {
width: 1250px;
height: 700px;
top: 42%;
right: -34%;
background: radial-gradient(
ellipse farthest-side at center,
var(--sv-accent) 0%,
transparent 100%
);
opacity: 0.12;
}
.glow-c {
width: 1530px;
height: 760px;
top: 20%;
left: -10%;
background: radial-gradient(
ellipse farthest-side at center,
color-mix(in srgb, var(--sv-ink) 5%, transparent) 0%,
transparent 100%
);
}
.inner {
position: relative;
z-index: 2;
max-width: var(--sv-maxw);
margin-inline: auto;
padding-inline: var(--sv-gutter);
}
/* ---------- heading ---------- */
.heading {
margin: 0 0 40px;
font-size: var(--sv-display);
line-height: 1;
font-weight: 650;
letter-spacing: -0.02em;
text-transform: uppercase;
text-align: center;
text-wrap: balance;
}
.showcase[data-reveal] [data-r-lift] {
opacity: 0;
transform: translateY(40px);
transition:
opacity 800ms cubic-bezier(0.16, 1, 0.3, 1),
transform 800ms cubic-bezier(0.16, 1, 0.3, 1);
}
.showcase[data-reveal] [data-r-lift][data-in] {
opacity: 1;
transform: none;
}
/* ---------- rows ---------- */
.rows {
display: flex;
flex-direction: column;
gap: 64px;
}
.row {
display: flex;
flex-direction: column;
gap: 32px;
}
/* ---------- window-framed media ---------- */
.media {
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
border: 1px solid var(--sv-frame);
border-radius: var(--sv-radius);
}
.chrome {
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: 29px;
flex-shrink: 0;
padding-inline: 10px;
background: var(--sv-chrome);
}
.dot {
width: 11px;
height: 11px;
border-radius: 50%;
}
/* The three dots are the one place a fixed hue is right: they read as a
window control only in these colours. */
.dot-1 {
background: #ff5f57;
}
.dot-2 {
background: #febc2e;
}
.dot-3 {
background: #28c840;
}
.stage {
position: relative;
display: grid;
place-items: center;
aspect-ratio: 16 / 9;
width: 100%;
overflow: hidden;
background: var(--sv-stage);
}
.fill {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Shown when an item carries no media: a drawn stand-in, not a broken box. */
.placeholder {
position: relative;
font-size: 0.8125rem;
font-weight: 500;
letter-spacing: 0.32em;
color: color-mix(in srgb, var(--sv-ink) 38%, transparent);
}
.stage:has(.placeholder)::before {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(
ellipse 70% 60% at 50% 45%,
color-mix(in srgb, var(--sv-accent) 26%, transparent) 0%,
transparent 70%
),
repeating-linear-gradient(
to bottom,
color-mix(in srgb, var(--sv-ink) 4%, transparent) 0 1px,
transparent 1px 5px
);
}
/* ---------- copy ---------- */
.copy {
display: flex;
flex-direction: column;
gap: 32px;
padding-inline: 8px;
}
.lede {
display: flex;
flex-direction: column;
gap: 8px;
}
.title {
margin: 0;
font-size: var(--sv-title);
line-height: 1.1;
font-weight: 650;
letter-spacing: -0.02em;
text-transform: uppercase;
}
.description {
margin: 0;
max-width: 46ch;
font-size: var(--sv-body);
line-height: 1.5;
color: var(--sv-muted);
text-wrap: pretty;
}
/* ---------- accordion ---------- */
.details {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.detail {
border-bottom: 1px solid var(--sv-hairline);
}
.detail-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
padding-block: 12px;
margin: 0;
font: inherit;
font-size: var(--sv-detail);
font-weight: 650;
line-height: 1.4;
letter-spacing: -0.01em;
text-transform: uppercase;
text-align: left;
color: inherit;
background: none;
border: 0;
cursor: pointer;
}
.detail-label {
min-width: 0;
}
.chevron {
flex-shrink: 0;
color: var(--sv-muted);
transition: rotate 300ms ease-out;
}
.detail[data-open] .chevron {
rotate: 180deg;
}
/*
* The 0fr -> 1fr grid row is what makes this animate to the content's own
* height without anyone measuring it. The inner element must own the
* overflow, or the text spills while the row is collapsed.
*/
.detail-panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease-out;
}
.detail[data-open] .detail-panel {
grid-template-rows: 1fr;
}
.detail-inner {
overflow: hidden;
}
.detail-body {
margin: 0;
max-width: 52ch;
padding-bottom: 16px;
font-size: var(--sv-body);
line-height: 1.6;
color: var(--sv-muted);
text-wrap: pretty;
}
/* ---------- call to action ---------- */
.cta {
display: inline-flex;
align-items: center;
gap: 16px;
max-width: 100%;
padding: 4px 20px 4px 4px;
border: 1px solid color-mix(in srgb, #000 10%, transparent);
border-radius: 8px;
background: var(--sv-ink);
text-decoration: none;
transition:
background-color 200ms linear,
border-color 200ms linear;
}
.cta-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 4px;
background: color-mix(in srgb, var(--sv-surface) 14%, #dcdce4);
color: var(--sv-surface);
transition:
background-color 200ms linear,
color 200ms linear,
scale 200ms linear;
}
.cta-label {
min-width: 0;
font-size: 0.875rem;
font-weight: 650;
line-height: 1.15;
letter-spacing: 0.01em;
text-transform: uppercase;
color: var(--sv-surface);
transition: color 200ms linear;
}
.cta:focus-visible {
outline: 2px solid var(--sv-ink);
outline-offset: 4px;
}
.detail-button:focus-visible {
outline: 2px solid var(--sv-ink);
outline-offset: -2px;
}
/* ---------- breakpoints ---------- */
@media (min-width: 768px) {
.showcase {
--sv-gutter: clamp(32px, 3.4vw, 48px);
}
.heading {
margin-bottom: 56px;
}
.rows {
gap: 96px;
}
.row {
flex-direction: row;
align-items: center;
}
/* Media takes the wider share; the copy column sits beside it. */
.media {
flex: 3;
align-self: center;
}
.copy {
flex: 2;
gap: 48px;
padding: 32px;
}
.showcase[data-alternate] .row:nth-child(even) {
flex-direction: row-reverse;
}
.cta {
padding-right: 28px;
gap: 24px;
}
.cta-icon {
width: 64px;
height: 64px;
border-radius: 6px;
}
.cta-label {
font-size: 1.125rem;
line-height: 1;
white-space: nowrap;
}
}
@media (min-width: 1024px) {
.copy {
padding-inline: 64px;
}
}
/* Hover is a pointer affordance. On a touch screen :hover sticks after the
finger lifts, which would leave a button stranded in its hover colour. */
@media (hover: hover) and (pointer: fine) {
.cta:hover {
background: var(--sv-accent);
border-color: transparent;
}
.cta:hover .cta-icon {
background: var(--sv-accent-deep);
color: var(--sv-ink);
scale: 1.04;
}
.cta:hover .cta-label {
color: var(--sv-ink);
}
.detail-button:hover .chevron {
color: var(--sv-ink);
}
}
@media (prefers-reduced-motion: reduce) {
.showcase[data-reveal] [data-r-lift] {
opacity: 1;
transform: none;
transition: none;
}
.detail-panel,
.chevron,
.cta,
.cta-icon,
.cta-label {
transition: none;
}
}
</style>
<noscript>
<style>
/* Without scripting the rows cannot be opened, so show every panel and
drop the affordance that implies otherwise. */
.showcase .detail-panel {
grid-template-rows: 1fr;
}
.showcase .chevron {
display: none;
}
.showcase[data-reveal] [data-r-lift] {
opacity: 1;
transform: none;
}
</style>
</noscript>
<script>
/*
* Three behaviours, all bound per instance so that two showcases on a page
* never reach into each other: the accordion, the heading reveal, and
* play-on-visible for any video.
*/
const reduceMotion = () =>
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ---- accordion ---- */
const setupAccordion = (root: HTMLElement) => {
/* Each list is its own exclusive group. Scoping the query to the list
rather than the root is what lets one row stay open while another
row's list is untouched. */
for (const list of root.querySelectorAll<HTMLElement>("[data-details]")) {
const items = [...list.querySelectorAll<HTMLElement>("[data-detail]")];
if (items.length === 0) continue;
const setOpen = (item: HTMLElement, open: boolean) => {
const button = item.querySelector<HTMLButtonElement>("[data-detail-button]");
const panel = item.querySelector<HTMLElement>("[data-detail-panel]");
if (!button || !panel) return;
item.toggleAttribute("data-open", open);
button.setAttribute("aria-expanded", open ? "true" : "false");
/* A collapsed panel is still in the accessibility tree and still
focusable; `inert` takes it out of both without disturbing the
height transition. */
panel.inert = !open;
};
for (const item of items) setOpen(item, item.hasAttribute("data-open"));
for (const item of items) {
const button = item.querySelector<HTMLButtonElement>("[data-detail-button]");
if (!button) continue;
button.addEventListener("click", () => {
const willOpen = !item.hasAttribute("data-open");
for (const other of items) setOpen(other, false);
if (willOpen) setOpen(item, true);
});
}
}
};
/* ---- heading reveal ---- */
const setupReveal = (root: HTMLElement) => {
if (!root.hasAttribute("data-reveal")) return;
const targets = [...root.querySelectorAll<HTMLElement>("[data-r-lift]")];
if (targets.length === 0) return;
if (!("IntersectionObserver" in window)) {
targets.forEach((target) => target.setAttribute("data-in", ""));
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.setAttribute("data-in", "");
observer.unobserve(entry.target);
}
},
/*
* The bottom margin sets the trigger point. The large top margin is
* what makes a jump safe: an observer reports only a *change* in
* intersection, so a heading that goes from below the viewport to
* above it in one jump — an anchor link, a restored scroll position
* — would read 0 both times, never fire, and stay invisible for
* good. Extending the root upwards means "already scrolled past"
* still counts as intersecting.
*/
{ rootMargin: "10000px 0px -10% 0px", threshold: 0 },
);
targets.forEach((target) => observer.observe(target));
};
/* ---- play videos only while they are on screen ---- */
const setupVideos = (root: HTMLElement) => {
const videos = [...root.querySelectorAll<HTMLVideoElement>("[data-video]")];
if (videos.length === 0) return;
/* An autoplaying loop is motion the reader did not ask for. Under a
reduced-motion preference the poster stands in for it. */
if (reduceMotion() || !("IntersectionObserver" in window)) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const video = entry.target as HTMLVideoElement;
if (entry.isIntersecting) {
/* A rejected play() is normal — a background tab, or a
policy that wants a gesture first. The poster stays. */
video.play().catch(() => {});
} else {
video.pause();
}
}
},
{ threshold: 0.25 },
);
videos.forEach((video) => observer.observe(video));
};
for (const root of document.querySelectorAll<HTMLElement>("[data-showcase]")) {
setupAccordion(root);
setupReveal(root);
setupVideos(root);
}
</script>
===== END src/components/ServiceShowcase.astro =====
===== FILE (supporting): src/components/ServiceShowcase.md =====
# Service showcase
A large display heading over alternating service rows. Each row pairs a
window-framed media panel with a title, a description, an exclusive accordion
of sub-items, and a call to action. The media side flips on every second row,
and everything stacks media-first on a phone.
Catalog page: `/components/service-showcase`
## Copying it into another Astro project
Copy **`ServiceShowcase.astro`** — one file, the whole component. There is
nothing else to move.
| 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 `--sv-*` property on the root |
| Webfonts | none — system stack |
| Bundled images, video or icons | **none** — see below |
| Network requests on load | none, unless you pass media |
| Host requirement | Astro compiles `.astro` files |
Three things that would normally be assets are drawn instead:
- the **film grain** is an inline `feTurbulence` SVG in a `data:` URI
- the **media placeholder** is a CSS gradient, shown whenever an item has no
`media`
- the **chevron and arrow icons** are inline `<svg>` paths
So the component ships with zero files beside it. Pass `media` on an item and
*that* file becomes your dependency — nothing else does.
```astro
---
import ServiceShowcase from "../components/ServiceShowcase.astro";
---
<ServiceShowcase
heading="What we do"
items={[
{
title: "Interface",
description: "Screens, states and the motion between them.",
details: [
{ label: "Design systems", body: "Tokens, components and the rules that keep them honest." },
{ label: "Prototypes", body: "Clickable builds at real fidelity." },
],
cta: { label: "See the work", href: "/interface" },
},
]}
/>
```
Every prop is optional, but the rows are your content: `items` defaults to
empty, and with no rows the component renders nothing — the heading alone never
shows. A row with no `title`, or an accordion row with no `label`, is dropped.
The two-row demo on the catalog page is passed in as props.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `ShowcaseItem[]` | `[]` | The service rows. Any count works; none renders nothing. |
| `heading` | `string` | `"What we do"` | Display heading over the rows. `""` hides it. Shown only with rows. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is the wrong level. |
| `alternate` | `boolean` | `true` | Flip the media side on every second row, at ≥768. |
| `openFirst` | `boolean` | `false` | Open each row's first accordion item on load. |
| `surface` | `string` | `"#0a0a0c"` | Section background. |
| `ink` | `string` | `"#ffffff"` | Text colour. Muted tones and hairlines derive from it. |
| `accent` | `string` | `"#4338ca"` | Button hover colour. |
| `accentDeep` | `string` | `"#312c9c"` | Icon tile colour on hover. |
| `glow` | `boolean` | `true` | Soft coloured glows behind the rows. |
| `grain` | `boolean` | `true` | Fine film grain over the section. |
| `reveal` | `boolean` | `true` | Fade and lift the heading in as the section arrives. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | auto | Root element id. Also seeds the internal element ids. |
### `ShowcaseItem` and friends
```ts
interface ShowcaseItem {
id?: string; // seeds this row's element ids
title: string; // service name, large display size
description?: string; // paragraph under the title
details?: ShowcaseDetail[]; // the accordion rows
media?: ShowcaseMedia; // omit for the CSS placeholder
placeholderLabel?: string; // caption drawn on the placeholder
cta?: ShowcaseCta; // omit for a row with no button
}
interface ShowcaseDetail {
id?: string;
label: string; // the accordion row's label
body?: string; // text revealed when it opens
}
interface ShowcaseMedia {
src: string; // image or video URL
video?: boolean; // treat src as a looping video
poster?: string; // still shown before play, and under reduced motion
alt?: string;
}
interface ShowcaseCta {
label: string;
href: string;
external?: boolean; // opens in a new tab with rel="noopener noreferrer"
}
```
`ink` and `surface` re-tint the section; the muted, hairline, frame, chrome and
stage tones are all `color-mix()` derivations, so one override moves them
together. The three window dots keep fixed hues on purpose — they only read as
window controls in red, amber and green.
## Layout
| | ≥1024 | 768–1023 | <768 |
| :--- | :--- | :--- | :--- |
| Row direction | row, alternating | row, alternating | column, media first |
| Media / copy share | 3 / 2 | 3 / 2 | full width |
| Row gap | 96px | 96px | 64px |
| Copy padding | 32px, 64px inline | 32px | 8px inline |
| Heading | 140px | ~92px | ~46px |
| Service title | 56px | 40px | 40px |
| Accordion label | 21px | 21px | 21px |
| CTA icon / label | 64px / 18px | 64px / 18px | 40px / 14px |
| Gutter | `clamp(32px, 3.4vw, 48px)` | ← | 24px |
The media panel is drawn as a small application window: a 29px chrome bar with
three 11px dots over a 16:9 stage, inside a 16px radius and a 1px frame.
The copy column's share is `flex: 2` against the media's `flex: 3`, but a long
uppercase label can push it wider than that — a flex item will not shrink below
its min-content width. The reference behaves the same way; if you want the
ratio held strictly, add `min-width: 0` to `.copy`.
## Behaviour
- **Accordion.** One row open at a time **within its own list**; opening a
second closes the first, and clicking the open one closes it. The panel
animates with `grid-template-rows: 0fr → 1fr` over `300ms ease-out`, which
reaches the content's own height without anything measuring it. The chevron
turns 180°.
- **Call to action.** The pill, its icon tile and its label recolour together
over `200ms linear`, and the tile scales to `1.04`. Gated behind
`(hover: hover) and (pointer: fine)`, because on a touch screen `:hover`
sticks after the finger lifts.
- **Heading reveal.** Rises 40px and fades in as the section arrives, once.
- **Video.** Any `media.video` item is `preload="none"` and plays only while it
is at least 25% on screen, pausing when it leaves. A rejected `play()` is
swallowed — a background tab or an autoplay policy simply leaves the poster
up.
- **Several per page.** Element ids are seeded from a per-instance uid, and
listeners are bound per accordion list, so two showcases never open each
other's rows and never collide on an id.
### Accessibility
- Each accordion row is a real `<button>` with `aria-expanded`, wired to its
panel by `aria-controls`; the panel points back with `aria-labelledby`.
- A collapsed panel is set `inert`, so its text is out of both the tab order
and the accessibility tree — a zero-height `overflow: hidden` box otherwise
leaves the content readable by a screen reader while invisible on screen.
- Focus rings are explicit on both the buttons and the CTA.
- The dots, chevrons, arrow and placeholder are `aria-hidden`.
### Reduced motion
Under `prefers-reduced-motion: reduce`:
- the heading is shown immediately, with no lift or fade
- the accordion opens and closes instantly — it still works, it just does not
animate
- the CTA and chevron transitions are removed
- **videos do not play.** An autoplaying loop is motion the reader did not ask
for, so the poster stands in for it.
### Without JavaScript
The pre-open state is CSS, so the first paint is already correct and nothing
flashes. A `<noscript>` block then opens every accordion panel and hides the
chevrons, because a row that cannot be opened should not advertise that it can.
All the text stays readable.
## Notes on the reference
The layout idea — a display heading over alternating rows, media framed as an
application window, an accordion of sub-items, a pill button with an icon tile
— was studied from a live marketing page. **None of that site's content came
with it.** The copy, the service names, the palette, the placeholder and the
grain are all written or drawn here; no text, logo, client name, photograph,
video or brand colour was copied. Replace `items` and `heading` with your own
and the design is unchanged.
===== END src/components/ServiceShowcase.md =====
--- 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.