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: "Framed hero"
Library page: https://astro.baysixmedia.com/components/framed-hero/
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/FramedHero.astro =====
---
/**
* FramedHero — a full-viewport hero drawn inside a rounded panel inset from
* the page edge, with a background image under a directional scrim, a headline
* that rises line by line, and a row of actions.
*
* 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.
*
* With no image passed the panel paints its own gradient scene, so the hero
* has nothing to download and still reads as a photograph-backed block.
*
* Measured behaviour:
* frame 12px inset around a 20px-radius panel, ring + drop + inset shadow
* panel min-height 100vh, content vertically centred, text left
* scrim left-to-right wash so the copy holds contrast over any image
* headline each line masked and lifted 100%, staggered
* copy subtitle and kicker follow the headline, then the actions
* actions pill buttons, recolour over 200ms
*
* Several instances can share a page: the reveal observer is built per root
* and every element it touches belongs to that root.
*/
export interface HeroAction {
label: string;
/**
* Destination. Omit it — or pass an empty string — and the action renders
* as a non-interactive pill rather than a button that goes nowhere.
*/
href?: string;
/** Filled or outlined. Defaults to filled for the first action. */
variant?: "solid" | "outline";
/** Open in a new tab, with the matching rel. */
external?: boolean;
}
interface Props {
/** Small wordmark above the headline. Empty string hides it. */
brand?: string;
/**
* The headline. Split on newlines: each line is masked and revealed
* separately, which is what produces the staggered rise.
*/
heading?: string;
/** Paragraph under the headline. Empty string hides it. */
subtitle?: string;
/** Small tracked line under the subtitle. Empty string hides it. */
kicker?: string;
/** Buttons at the foot of the copy. `[]` hides the row. */
actions?: HeroAction[];
/** Background image URL. Omit for the drawn gradient scene. */
image?: string;
/** `srcset` for the background image, when you have several sizes. */
imageSrcset?: string;
/** `sizes` to go with `imageSrcset`. */
imageSizes?: string;
/** Alternative text. Leave empty when the image is decorative. */
imageAlt?: string;
/** `object-position` for the background image. */
imagePosition?: string;
/** The wash over the image. Any CSS gradient. */
scrim?: string;
/** Fill the viewport. Off lets the hero be only as tall as its content. */
fullHeight?: boolean;
/** Page colour behind the inset panel. */
surround?: string;
/** Panel colour, seen where the image does not reach. */
panelSurface?: string;
/** Headline and action colour. */
ink?: string;
/** Tint of the drawn scene when no image is passed. */
accent?: string;
/** Corner radius of the panel. */
radius?: string;
/** Reveal the copy as the hero arrives. */
reveal?: boolean;
/** Heading level. A hero is usually the page's h1. */
headingLevel?: "h1" | "h2";
class?: string;
id?: string;
}
const {
/* No copy of its own: the brand, headline, lines and actions are the
caller's. The catalog demo passes them through the registry's props. */
brand = "",
heading = "",
subtitle = "",
kicker = "",
actions = [],
image,
imageSrcset,
imageSizes,
imageAlt = "",
imagePosition = "right center",
scrim = "linear-gradient(to right, rgb(0 0 0 / 0.62) 0%, rgb(0 0 0 / 0.28) 45%, transparent 100%)",
fullHeight = true,
surround = "#eef0f2",
panelSurface = "#121317",
ink = "#ffffff",
accent = "#2f4bd8",
radius = "20px",
reveal = true,
headingLevel = "h1",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
const lines = heading ? heading.split("\n").map((l) => l.trim()).filter(Boolean) : [];
const isDestination = (href?: string) => {
const trimmed = href?.trim();
return !!trimmed && trimmed !== "#";
};
/* An action with no label has nothing to say: it is not drawn. */
const actionList = actions.filter((action) => !!action?.label);
/* Index the copy blocks so each can follow the last without hard-coding. */
const copyDelay = (step: number) => `${lines.length * 110 + step * 90}ms`;
/* A backdrop with nothing on it is not a hero: render nothing. */
const hasContent = !!brand || lines.length > 0 || !!subtitle || !!kicker || actionList.length > 0;
---
{hasContent && (
<section
class:list={["frame", className]}
id={id}
data-framed-hero
data-reveal={reveal ? "" : undefined}
data-full={fullHeight ? "" : undefined}
style={`--fh-surround:${surround};--fh-panel:${panelSurface};--fh-ink:${ink};--fh-accent:${accent};--fh-radius:${radius};--fh-scrim:${scrim};--fh-image-pos:${imagePosition};`}
>
<div class="panel">
{
image ? (
<img
class="backdrop"
src={image}
srcset={imageSrcset}
sizes={imageSizes}
alt={imageAlt}
decoding="async"
fetchpriority="high"
/>
) : (
<div class="scene" aria-hidden="true" />
)
}
<div class="scrim" aria-hidden="true"></div>
<div class="content">
<div class="copy">
{
brand && (
<p class="brand" data-lift style="--d:0ms">
{brand}
</p>
)
}
{
lines.length > 0 && (
<Heading class="heading">
{lines.map((line, i) => (
<span class="line-mask">
<span class="line" style={`--i:${i}`}>
{line}
</span>
</span>
))}
</Heading>
)
}
{
subtitle && (
<p class="subtitle" data-lift style={`--d:${copyDelay(0)}`}>
{subtitle}
</p>
)
}
{
kicker && (
<p class="kicker" data-lift style={`--d:${copyDelay(1)}`}>
{kicker}
</p>
)
}
{
actionList.length > 0 && (
<div class="actions" data-lift style={`--d:${copyDelay(2)}`}>
{actionList.map((action, i) => {
const variant = action.variant ?? (i === 0 ? "solid" : "outline");
return isDestination(action.href) ? (
<a
class:list={["action", variant]}
href={action.href}
target={action.external ? "_blank" : undefined}
rel={action.external ? "noopener noreferrer" : undefined}
>
{action.label}
</a>
) : (
<span class:list={["action", variant, "action-static"]}>
{action.label}
</span>
);
})}
</div>
)
}
</div>
</div>
</div>
</section>
)}
<style>
.frame {
--fh-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
/* 30px at a 390px viewport, 60px from about 1280px up. */
--fh-display: clamp(1.875rem, 0.85rem + 3.2vw, 3.75rem);
--fh-body: clamp(1rem, 0.94rem + 0.2vw, 1.125rem);
--fh-ease: cubic-bezier(0.16, 1, 0.3, 1);
box-sizing: border-box;
display: flex;
padding: 12px;
background: var(--fh-surround);
font-family: var(--fh-font);
}
.frame :where(*, *::before, *::after) {
box-sizing: border-box;
}
.panel {
position: relative;
flex: 1;
min-width: 0;
display: flex;
overflow: hidden;
border-radius: var(--fh-radius);
background: var(--fh-panel);
color: var(--fh-ink);
box-shadow:
0 0 0 1px rgb(0 0 0 / 0.05),
0 8px 30px rgb(0 0 0 / 0.12),
inset 0 1px 0 rgb(255 255 255 / 0.1);
}
.frame[data-full] .panel {
min-height: calc(100vh - 24px);
min-height: calc(100svh - 24px);
}
/* ---------- backdrop ---------- */
.backdrop {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: var(--fh-image-pos);
}
/* Drawn stand-in for the photograph: a lit corner over a deep ground. */
.scene {
position: absolute;
inset: 0;
z-index: 0;
background:
radial-gradient(
ellipse 62% 78% at 78% 38%,
color-mix(in srgb, var(--fh-accent) 85%, #fff 15%) 0%,
color-mix(in srgb, var(--fh-accent) 55%, transparent) 38%,
transparent 72%
),
radial-gradient(
ellipse 70% 60% at 22% 88%,
color-mix(in srgb, var(--fh-accent) 30%, transparent) 0%,
transparent 70%
),
linear-gradient(140deg, #16171d 0%, var(--fh-panel) 55%, #0c0d11 100%);
}
.scrim {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: var(--fh-scrim);
}
/* ---------- content ---------- */
.content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
min-height: 0;
padding: 56px 32px;
}
.copy {
width: 100%;
max-width: 48rem;
}
.brand {
margin: 0 0 18px;
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--fh-ink);
}
.heading {
margin: 0;
font-size: var(--fh-display);
line-height: 1.1;
font-weight: 800;
letter-spacing: -0.025em;
text-wrap: balance;
}
/*
* Each line gets a clipping box of its own. The bottom padding, cancelled
* by an equal negative margin, gives descenders the room the clip would
* otherwise cut without changing where the line sits.
*/
.line-mask {
display: block;
overflow: hidden;
padding-bottom: 0.08em;
margin-bottom: -0.08em;
}
.line {
display: block;
}
.subtitle {
max-width: 36rem;
margin: 24px 0 0;
font-size: var(--fh-body);
line-height: 1.6;
color: color-mix(in srgb, var(--fh-ink) 72%, transparent);
text-wrap: pretty;
}
.kicker {
margin: 12px 0 0;
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: color-mix(in srgb, var(--fh-ink) 60%, transparent);
}
.actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
margin-top: 32px;
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 14px 28px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 0.875rem;
font-weight: 700;
line-height: 1.2;
text-align: center;
text-decoration: none;
transition:
background-color 200ms cubic-bezier(0.4, 0, 0.2, 1),
border-color 200ms cubic-bezier(0.4, 0, 0.2, 1),
color 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.solid {
background: var(--fh-ink);
color: #0a0b0e;
}
.outline {
border-color: color-mix(in srgb, var(--fh-ink) 70%, transparent);
background: rgb(0 0 0 / 0.25);
color: var(--fh-ink);
}
/*
* An action 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 a button that looks live.
*/
.action-static {
opacity: 0.55;
cursor: default;
transition: none;
}
.action:focus-visible {
outline: 2px solid var(--fh-ink);
outline-offset: 3px;
}
/* ---------- reveal ---------- */
.frame[data-reveal] .line {
transform: translateY(100%);
opacity: 0;
transition:
transform 800ms var(--fh-ease) calc(var(--i) * 110ms),
opacity 800ms var(--fh-ease) calc(var(--i) * 110ms);
}
.frame[data-reveal] [data-lift] {
opacity: 0;
transform: translateY(16px);
transition:
opacity 700ms var(--fh-ease) var(--d, 0ms),
transform 700ms var(--fh-ease) var(--d, 0ms);
}
.frame[data-reveal] [data-in] .line,
.frame[data-reveal] [data-lift][data-in] {
transform: none;
opacity: 1;
}
/* ---------- breakpoints ---------- */
@media (min-width: 640px) {
.actions {
flex-direction: row;
flex-wrap: wrap;
}
.action {
width: auto;
}
}
@media (min-width: 768px) {
.content {
padding: 56px;
}
}
@media (min-width: 1024px) {
.content {
padding: 64px 80px;
}
.brand {
margin-bottom: 20px;
}
}
@media (hover: hover) and (pointer: fine) {
a.solid:hover {
background: color-mix(in srgb, var(--fh-ink) 90%, transparent);
}
a.outline:hover {
background: var(--fh-ink);
color: #0a0b0e;
}
}
@media (prefers-reduced-motion: reduce) {
.frame[data-reveal] :is(.line, [data-lift]) {
opacity: 1;
transform: none;
transition: none;
}
.action {
transition: none;
}
}
</style>
<noscript>
<style>
.frame[data-reveal] :is(.line, [data-lift]) {
opacity: 1;
transform: none;
}
</style>
</noscript>
<script>
/*
* One observer per instance, closed over that instance's own elements, so
* two heroes on a page reveal independently.
*/
const setup = (root: HTMLElement) => {
if (!root.hasAttribute("data-reveal")) return;
const blocks = [...root.querySelectorAll<HTMLElement>(".heading, [data-lift]")];
if (blocks.length === 0) return;
if (!("IntersectionObserver" in window)) {
blocks.forEach((block) => block.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);
}
},
/*
* A hero is normally in view on load, so this fires immediately. The
* large top margin keeps it correct anywhere else on the page: an
* observer reports only a *change* in intersection, so a block
* jumped over would read 0 both times and never reveal.
*/
{ rootMargin: "10000px 0px -5% 0px", threshold: 0 },
);
blocks.forEach((block) => observer.observe(block));
};
document.querySelectorAll<HTMLElement>("[data-framed-hero]").forEach(setup);
</script>
===== END src/components/FramedHero.astro =====
===== FILE (supporting): src/components/FramedHero.md =====
# Framed hero
A full-viewport hero drawn inside a rounded panel inset from the page edge,
with a backdrop under a directional scrim, a headline that rises line by line,
and a row of actions.
Catalog page: `/components/framed-hero`
## Copying it into another Astro project
Copy **`FramedHero.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 `--fh-*` property on the root |
| Webfonts | none — system stack |
| Bundled images | **none in the component** — but the catalog demo ships two; see below |
| Network requests on load | none, unless you pass `image` |
| Host requirement | Astro compiles `.astro` files |
**The `.astro` file has no asset dependency.** With no `image` prop the panel
paints its own gradient scene, so the component alone downloads nothing and
still reads as a backed block.
## The demo's image dependency
The catalog demo **does** pass an image, so copying the demo as-you-see-it
needs two files as well as the component:
```
public/framed-hero/backdrop-1440.webp (10 KB)
public/framed-hero/backdrop-1920.webp (16 KB)
```
Nothing in `FramedHero.astro` imports them — they are referenced by URL through
the `image` and `imageSrcset` props — so they will not follow the component on
their own. The catalog's source panel lists them under **assets** for that
reason, and the copy button includes them.
You have three options:
**Copy the images too.** Put the `public/framed-hero/` folder in your own
`public/` and the demo props work untouched.
**Point at your own image.** One prop, and the two files are irrelevant:
```astro
<FramedHero
image="/your-hero.webp"
imageSrcset="/your-hero-1440.webp 1440w, /your-hero-1920.webp 1920w"
imageSizes="100vw"
imageAlt=""
imagePosition="right center"
/>
```
**Drop the image entirely.** Omit `image` and the drawn gradient scene takes
over — no files, no requests, and every other behaviour is unchanged.
### About the bundled image
It is **original**, generated for this catalog: a CSS composition of blurred
colour blooms, a raking light streak, a vignette and fine grain, rendered
headless at 1920×1080 and 1440×810 and saved as WebP. There is no photographer
to credit, no licence to track, and no third-party asset involved. It is
deliberately **quiet on the left and lit on the right**, which is what lets the
left-to-right scrim hold white copy at readable contrast — see *Contrast*
below.
The two sizes exist so `srcset` can serve the smaller file to ordinary displays
and the larger one to wide or high-density screens.
### Exact steps
1. Copy `FramedHero.astro` into your components folder, e.g.
`src/components/FramedHero.astro`.
2. Render it as the first thing in your `<body>`, above your main content.
```astro
---
import FramedHero from "../components/FramedHero.astro";
---
<FramedHero
brand="Your Studio"
heading={"Interfaces built\nto outlast the launch."}
subtitle="One or two sentences of positioning."
kicker="Studio practice · Systems over screens"
image="/hero.webp"
imageSrcset="/hero-1440.webp 1440w, /hero-1920.webp 1920w"
imageSizes="100vw"
imageAlt=""
actions={[
{ label: "Start a project", href: "/contact", variant: "solid" },
{ label: "See the work", href: "/work", variant: "outline" },
]}
/>
```
3. Set `surround` to your page background so the 12px frame blends in rather
than showing a band of the wrong colour.
**Newlines in `heading` are meaningful.** Each line is masked and revealed
separately — that is what produces the staggered rise. Use a template literal
(`{"First line\nSecond line"}`) and break where you want the line to break.
**The component ships no copy of its own.** `brand`, `heading`, `subtitle`,
`kicker` and `actions` all default to empty, so a copied hero never carries the
catalog's fictional studio, its positioning or its `mailto:` address. The
catalog demo passes them through the registry's `props`. With none of them
supplied the hero renders nothing at all — a backdrop with no words is not a
hero. The demo's second action has no `href` on purpose, so it renders as a
non-interactive pill rather than a button that looks live and goes nowhere —
see below.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `heading` | `string` | `""` | Split on `\n`; each line reveals separately. `""` hides it. |
| `brand` | `string` | `""` | Wordmark above the headline. `""` hides it. |
| `subtitle` | `string` | `""` | Paragraph under the headline. `""` hides it. |
| `kicker` | `string` | `""` | Tracked line under the subtitle. `""` hides it. |
| `actions` | `HeroAction[]` | `[]` | Buttons. `[]` hides the row. |
| `headingLevel` | `"h1" \| "h2"` | `"h1"` | A hero is usually the page's `h1`. |
| `image` | `string` | — | Backdrop URL. Omit for the drawn scene. |
| `imageSrcset` | `string` | — | `srcset` for the backdrop. |
| `imageSizes` | `string` | — | `sizes` to go with the srcset. |
| `imageAlt` | `string` | `""` | Leave empty when the backdrop is decorative. |
| `imagePosition` | `string` | `"right center"` | `object-position` for the backdrop. |
| `scrim` | `string` | left-to-right black wash | Any CSS gradient over the backdrop. |
| `fullHeight` | `boolean` | `true` | Fill the viewport, rather than fitting content. |
| `surround` | `string` | `"#eef0f2"` | Page colour behind the inset panel. |
| `panelSurface` | `string` | `"#121317"` | Panel colour where the backdrop does not reach. |
| `ink` | `string` | `"#ffffff"` | Headline and action colour. |
| `accent` | `string` | `"#2f4bd8"` | Tint of the drawn scene. |
| `radius` | `string` | `"20px"` | Corner radius of the panel. |
| `reveal` | `boolean` | `true` | Reveal the copy on arrival. |
| `class` / `id` | `string` | — | Added to / set on the root `<section>`. |
### `HeroAction`
```ts
interface HeroAction {
label: string;
href?: string; // omit -> non-interactive pill, not a dead button
variant?: "solid" | "outline"; // defaults to solid for the first action
external?: boolean; // target="_blank" rel="noopener noreferrer"
}
```
**`href` is optional, and that is deliberate.** Give one and you get a real
`<a>`. Leave it out — or pass `""` or `"#"` — and the action renders as a
`<span>`: dimmed, default cursor, out of the tab order, no hover. Nothing in
this hero can look like a button while doing nothing.
## Layout
| | ≥1024 | 768–1023 | 640–767 | <640 |
| :--- | :--- | :--- | :--- | :--- |
| Panel padding | 64px / 80px | 56px | 56px / 32px | 56px / 32px |
| Headline | ~55px | ~40px | fluid | 30px |
| Actions | row | row | row | **column, full width** |
| Frame | 12px inset, 20px radius | ← | ← | ← |
The scrim is a left-to-right wash by default, which is what lets white copy sit
over the light half of a photograph. Change `scrim` if your image is lit from
the other side.
### Contrast
White copy over an image is only safe if you check it, so the bundled backdrop
was measured under every text element at all three widths — sampling the real
image pixels, compositing the scrim over them exactly as CSS does, and
comparing the result to white:
| | headline (needs 3:1) | subtitle (needs 4.5:1) | kicker (needs 4.5:1) |
| :--- | :--- | :--- | :--- |
| Desktop 1280 | 14.0:1 | 17.7:1 | 19.0:1 |
| Tablet 834 | **7.4:1** | 10.3:1 | 12.2:1 |
| Mobile 390 | 9.9:1 | 12.0:1 | 12.7:1 |
The worst case anywhere under real text is 7.4:1 against a 3:1 requirement.
Tablet is the tightest because the portrait crop pulls the bloom in behind the
headline.
**If you swap the image, re-check this.** A backdrop that is bright on the left
will fail where this one passes, and the fix is either a different
`imagePosition`, a stronger `scrim`, or an image lit from the other side.
### How the image is cropped
The backdrop is `object-fit: cover` with `object-position: right center`, so as
the panel narrows it keeps the right edge and crops the left:
| | visible slice of the source width |
| :--- | :--- |
| Desktop 1280 | 91% |
| Tablet 834 | 42% |
| Mobile 390 | 25% |
That is why an image for this component wants its subject on the **right** —
at phone widths only the right quarter survives. Set `imagePosition` to move
the anchor if yours is composed the other way.
## Behaviour
- **Headline.** Each line sits in its own clipping box and starts at
`translateY(100%)`, rising over `800ms` on a `110ms` cascade. The mask carries
a bottom padding cancelled by an equal negative margin, so descenders are not
clipped and the line does not move.
- **Copy.** Wordmark, subtitle, kicker and actions each lift `16px` and fade,
timed to follow the last headline line.
- **Actions.** Colour transitions over `200ms`. The solid pill fades slightly on
hover; the outline pill inverts to solid. Gated behind
`(hover: hover) and (pointer: fine)`.
- **Focus.** Every real action draws a `2px` outline in `ink` at a `3px` offset
on `:focus-visible`. Static actions are `<span>`, so they are not focusable at
all — no `tabindex="-1"` needed.
- **Several per page.** The observer is built per root, so two heroes reveal
independently.
- **Reduced motion:** `prefers-reduced-motion: reduce` shows the headline and
copy immediately and removes the action transitions.
### Why the reveal uses an observer for an above-the-fold block
A hero is normally in view on load, so the observer fires immediately and the
result is the same as revealing on mount. Using an observer anyway means the
component still behaves if you put it lower on a page. The margin is generous
at the top for the usual reason: an `IntersectionObserver` reports only a
*change* in intersection, so a block jumped over — an anchor link, a restored
scroll position — would read `0` both times and never reveal.
### Without JavaScript
The pre-reveal state is CSS keyed on `data-reveal`, so the first paint is
correct and nothing flashes. A `<noscript>` block then shows the headline and
copy. The backdrop, scrim, layout and hover states are all CSS and never
depended on the script.
## Notes on the reference
The idea — a full-viewport hero inside a rounded inset panel, a scrimmed
backdrop, a headline masked line by line, and a pair of pill actions — was
studied from a live marketing page. **None of that site's content came with
it.** The studio name, headline, subtitle, kicker and action labels are written
for this catalog and the studio is fictional; no text, photograph, logo, colour
or asset was copied. The bundled backdrop is generated for this catalog, not
sourced — the reference's own hero photograph is not used, reproduced or
resembled.
One thing worth knowing about the reference: the section this was drawn from is
the **desktop variant only** — it carries `hidden md:flex`, and below `768px`
the page swaps to an entirely separate sibling element. This component is a
single element that handles all three widths itself.
===== END src/components/FramedHero.md =====
===== FILE (required): public/framed-hero/backdrop-1440.webp =====
[binary asset — download https://astro.baysixmedia.com/framed-hero/backdrop-1440.webp and save it to public/framed-hero/backdrop-1440.webp]
===== FILE (required): public/framed-hero/backdrop-1920.webp =====
[binary asset — download https://astro.baysixmedia.com/framed-hero/backdrop-1920.webp and save it to public/framed-hero/backdrop-1920.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.