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: "Pebble frame hero"
Library page: https://astro.baysixmedia.com/components/pebble-frame-hero/
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/PebbleFrameHero.astro =====
---
/**
* PebbleFrameHero
*
* A warm, light split hero: a friendly headline, a short welcome, two
* rounded actions and a few plain highlights on one side; on the other, a
* photo in a soft pebble-shaped frame ringed by two small coloured shapes.
* With no photo the frame holds a drawn scene instead — a low sun over two
* rolling hills in the same palette — so the hero reads as designed, not as
* waiting for an image.
*
* Nothing here is a claim unless it is passed. Highlights, licensing,
* accreditation, a founding year and a rating each render only from real
* data; left out, they leave no gap, no label and no placeholder.
*
* Motion: the copy and the frame settle in once on load (opacity and a few
* pixels of rise). CSS only, ends at rest, and off under reduced motion.
*
* No script. Reads --pfh-* tokens from any ancestor, with fallbacks.
*/
export interface PebbleFrameHeroAction {
label: string;
href: string;
}
export interface PebbleFrameHeroProps {
heading: string;
headingLevel?: "h1" | "h2";
/** A short line above the headline. */
eyebrow?: string;
body?: string;
/** Filled rounded button — "Schedule a tour", "Enrol now". */
primaryCta?: PebbleFrameHeroAction | null;
/** Outlined rounded button — "Explore programmes". */
secondaryCta?: PebbleFrameHeroAction | null;
/** Plain facts the organisation states about itself: ages served, hours, class sizes. */
highlights?: readonly string[];
/** Licence line as issued, e.g. "Licensed by the state, facility #123456789". */
license?: string;
/** An accreditation actually held, named as the accrediting body names it. */
accreditation?: string;
/** A real founding line, e.g. "Family-run since 2004". */
established?: string;
/** Real review data only. Omit it and no rating is shown. */
rating?: { value: number; count?: number; source?: string } | null;
/** Photo for the pebble frame. Omit it for the drawn scene. */
image?: { src: string; alt?: string } | null;
/** Run the settle-in animation. */
animate?: boolean;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends PebbleFrameHeroProps {}
const {
heading,
headingLevel = "h1",
eyebrow = "",
body = "",
primaryCta = null,
secondaryCta = null,
highlights = [],
license = "",
accreditation = "",
established = "",
rating = null,
image = null,
animate = true,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Nunito:wght@500;600;700;800&display=swap",
class: className,
id,
} = Astro.props;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
/* A rating is shown only when it is a real number in range. */
const ratingValue =
rating && typeof rating.value === "number" && Number.isFinite(rating.value) && rating.value >= 0 && rating.value <= 5
? rating.value
: null;
const ratingCount =
ratingValue !== null && typeof rating?.count === "number" && Number.isFinite(rating.count) && rating.count > 0
? Math.round(rating.count)
: null;
const ratingText =
ratingValue === null
? ""
: [
`Rated ${ratingValue.toFixed(1)} out of 5`,
ratingCount !== null ? `from ${ratingCount.toLocaleString("en-US")} reviews` : "",
rating?.source ? `on ${rating.source}` : "",
]
.filter(Boolean)
.join(" ");
const points = (highlights ?? []).map((h) => h.trim()).filter(Boolean);
const trust = [license, accreditation, established].map((t) => t.trim()).filter(Boolean);
const photo = image?.src ? { src: image.src, alt: image.alt ?? "" } : null;
const Heading = headingLevel;
---
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["pfh", className]} id={id} data-animate={animate ? "" : undefined}>
<div class="pfh-inner">
<div class="pfh-copy pfh-settle">
{eyebrow && <p class="pfh-eyebrow">{eyebrow}</p>}
<Heading class="pfh-title">{heading}</Heading>
{body && <p class="pfh-body">{body}</p>}
{
(primaryCta || secondaryCta) && (
<div class="pfh-actions">
{primaryCta && (
<a class="pfh-btn pfh-btn--primary" href={safeHref(primaryCta.href)}>
{primaryCta.label}
</a>
)}
{secondaryCta && (
<a class="pfh-btn pfh-btn--secondary" href={safeHref(secondaryCta.href)}>
{secondaryCta.label}
</a>
)}
</div>
)
}
{
points.length > 0 && (
<ul class="pfh-points" role="list">
{points.map((point) => (
<li>
<span class="pfh-dot" aria-hidden="true" />
{point}
</li>
))}
</ul>
)
}
{
(trust.length > 0 || ratingValue !== null) && (
<ul class="pfh-trust" role="list">
{trust.map((line) => (
<li class="pfh-chip">{line}</li>
))}
{ratingValue !== null && (
<li class="pfh-chip">
<svg class="pfh-star" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="m12 2.8 2.8 5.8 6.4.9-4.6 4.5 1.1 6.3L12 17.3l-5.7 3 1.1-6.3-4.6-4.5 6.4-.9Z" fill="currentColor" />
</svg>
{ratingText}
</li>
)}
</ul>
)
}
</div>
<div class="pfh-art pfh-settle" aria-hidden={photo ? undefined : "true"}>
<span class="pfh-shape pfh-shape--sun" aria-hidden="true"></span>
<span class="pfh-shape pfh-shape--leaf" aria-hidden="true"></span>
<div class="pfh-frame">
{
photo ? (
<img src={photo.src} alt={photo.alt} width="640" height="700" loading="eager" decoding="async" />
) : (
<div class="pfh-scene">
<span class="pfh-scene-sun" />
<span class="pfh-scene-hill pfh-scene-hill--back" />
<span class="pfh-scene-hill pfh-scene-hill--front" />
</div>
)
}
</div>
</div>
</div>
</section>
<style>
.pfh {
--pfh-surface: var(--pfh-bg, #fff8ef);
--pfh-ink: var(--pfh-text, #2b2a3a);
--pfh-muted: var(--pfh-text-muted, #5c5a6e);
--pfh-primary: var(--pfh-brand, #e2674b);
--pfh-primary-ink: var(--pfh-brand-text, #ffffff);
--pfh-sage: var(--pfh-accent-1, #9cc5a1);
--pfh-sun: var(--pfh-accent-2, #f6c65b);
--pfh-sky: var(--pfh-accent-3, #cfe6f2);
--pfh-font: var(--pfh-family, Nunito, ui-rounded, ui-sans-serif, system-ui, sans-serif);
--pfh-ease: cubic-bezier(0.22, 0.8, 0.3, 1);
overflow: clip;
background: var(--pfh-surface);
color: var(--pfh-ink);
font-family: var(--pfh-font);
}
.pfh *,
.pfh *::before,
.pfh *::after {
box-sizing: border-box;
}
.pfh-inner {
display: grid;
gap: 40px;
max-width: 1200px;
margin: 0 auto;
padding: 48px 16px 64px;
}
.pfh-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.pfh-eyebrow {
margin: 0 0 12px;
padding: 6px 14px;
border-radius: 999px;
background: color-mix(in srgb, var(--pfh-sage) 35%, transparent);
font: 700 14px/1.3 var(--pfh-font);
}
.pfh-title {
margin: 0;
max-width: 16ch;
font: 800 clamp(34px, 7.5vw, 56px) / 1.1 var(--pfh-font);
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.pfh-body {
max-width: 52ch;
margin: 18px 0 0;
color: var(--pfh-muted);
font: 600 18px/1.6 var(--pfh-font);
overflow-wrap: anywhere;
}
.pfh-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 28px;
}
.pfh-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 50px;
padding: 12px 26px;
border: 2px solid var(--pfh-primary);
border-radius: 999px;
font: 800 17px/1.2 var(--pfh-font);
text-align: center;
text-decoration: none;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.pfh-btn--primary {
background: var(--pfh-primary);
color: var(--pfh-primary-ink);
}
.pfh-btn--primary:hover {
background: var(--pfh-ink);
border-color: var(--pfh-ink);
}
.pfh-btn--secondary {
background: transparent;
color: var(--pfh-ink);
}
.pfh-btn--secondary:hover {
background: color-mix(in srgb, var(--pfh-primary) 12%, transparent);
}
.pfh-btn:focus-visible {
outline: 3px solid var(--pfh-ink);
outline-offset: 3px;
}
.pfh-points {
display: grid;
gap: 10px;
margin: 28px 0 0;
padding: 0;
list-style: none;
}
.pfh-points li {
display: flex;
align-items: flex-start;
gap: 10px;
font: 700 16px/1.5 var(--pfh-font);
overflow-wrap: anywhere;
}
.pfh-dot {
flex: none;
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 50%;
background: var(--pfh-sage);
}
.pfh-trust {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 24px 0 0;
padding: 0;
list-style: none;
}
.pfh-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 14px;
background: #fff;
box-shadow: 0 1px 0 rgb(43 42 58 / 0.08);
color: var(--pfh-muted);
font: 700 14px/1.3 var(--pfh-font);
overflow-wrap: anywhere;
}
.pfh-star {
flex: none;
width: 16px;
height: 16px;
color: #d98a0b;
}
/* ---- the pebble frame ---- */
.pfh-art {
position: relative;
width: min(100%, 520px);
margin: 0 auto;
padding: 18px;
}
.pfh-frame {
position: relative;
aspect-ratio: 9 / 10;
overflow: hidden;
border-radius: 46% 54% 42% 58% / 52% 44% 56% 48%;
background: var(--pfh-sky);
}
.pfh-frame img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.pfh-shape {
position: absolute;
z-index: 1;
border-radius: 50%;
}
/* A ring in the button colour, so it frames the pebble rather than
repeating the sun drawn inside it. */
.pfh-shape--sun {
top: 2%;
right: 6%;
width: 20%;
aspect-ratio: 1;
border: clamp(8px, 2.2vw, 14px) solid color-mix(in srgb, var(--pfh-primary) 70%, #fff);
background: transparent;
}
.pfh-shape--leaf {
bottom: 4%;
left: 0;
width: 26%;
aspect-ratio: 1;
border-radius: 0 60% 0 60%;
background: var(--pfh-sage);
}
/* The drawn scene: a low sun behind two rolling hills. */
.pfh-scene {
position: absolute;
inset: 0;
background: linear-gradient(180deg, var(--pfh-sky) 0%, color-mix(in srgb, var(--pfh-sky) 60%, #fff) 70%);
}
.pfh-scene-sun {
position: absolute;
top: 24%;
left: 50%;
width: 34%;
aspect-ratio: 1;
border-radius: 50%;
background: var(--pfh-sun);
transform: translateX(-50%);
}
.pfh-scene-hill {
position: absolute;
bottom: -18%;
width: 110%;
aspect-ratio: 1.6;
border-radius: 50%;
}
.pfh-scene-hill--back {
left: -40%;
background: color-mix(in srgb, var(--pfh-sage) 70%, #fff);
}
.pfh-scene-hill--front {
right: -45%;
bottom: -30%;
background: var(--pfh-sage);
}
/* ---- settle-in: ends at rest, nothing hidden without CSS animation ---- */
@keyframes pfh-settle {
from {
opacity: 0;
transform: translateY(12px);
}
}
.pfh[data-animate] .pfh-settle {
animation: pfh-settle 0.7s var(--pfh-ease) both;
}
.pfh[data-animate] .pfh-art {
animation-delay: 0.12s;
}
@media (min-width: 900px) {
.pfh-inner {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
align-items: center;
gap: 56px;
padding: 88px 32px;
}
.pfh-art {
margin: 0;
justify-self: end;
}
}
@media (prefers-reduced-motion: reduce) {
.pfh[data-animate] .pfh-settle {
animation: none;
}
.pfh-btn {
transition: none;
}
}
</style>
===== END src/components/PebbleFrameHero.astro =====
===== FILE (supporting): src/components/PebbleFrameHero.md =====
# Pebble frame hero
A warm, light split hero for organisations that welcome people in —
childcare and early learning, tutoring, family services, a community clinic.
A friendly headline, a short welcome, two rounded actions and a few plain
highlights sit beside a photo in a soft pebble-shaped frame, ringed by two
small coloured shapes. With no photo the frame holds a drawn scene instead: a
low sun over two rolling hills in the same palette.
Catalog page: `/components/pebble-frame-hero`
## Files required
- **`PebbleFrameHero.astro`** — the whole component. No script, no images.
- `PebbleFrameHero.md` — this file.
## Basic usage
```astro
<PebbleFrameHero
heading="Where little ones love to learn"
body="Play-based early learning in small groups, five minutes from downtown."
primaryCta={{ label: "Schedule a tour", href: "/tour" }}
secondaryCta={{ label: "Explore programs", href: "/programs" }}
highlights={["Ages 2 to 5", "Open weekdays, 7:30am to 6pm"]}
image={{ src: "/img/classroom.webp", alt: "Children painting at a low table" }}
/>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `heading` | `string` | **required** | The page's headline. |
| `headingLevel` | `"h1" \| "h2"` | `"h1"` | `h2` for a second use on one page. |
| `eyebrow` | `string?` | `""` | Rounded chip above the headline. |
| `body` | `string?` | `""` | The welcome. |
| `primaryCta` | `{ label, href } \| null` | `null` | Filled rounded button. |
| `secondaryCta` | `{ label, href } \| null` | `null` | Outlined rounded button. |
| `highlights` | `string[]?` | `[]` | Plain facts, each with a dot. |
| `license` | `string?` | `""` | Licence line as issued. |
| `accreditation` | `string?` | `""` | An accreditation actually held. |
| `established` | `string?` | `""` | A real founding line. |
| `rating` | `{ value, count?, source? } \| null` | `null` | Real review data only. |
| `image` | `{ src, alt? } \| null` | `null` | Photo for the pebble frame. |
| `animate` | `boolean?` | `true` | The settle-in on load. |
| `fontStylesheet` | `string?` | Nunito | `""` skips it. |
| `class`, `id` | | | Root attributes. |
The call-to-action labels are props: "Schedule a tour", "Explore programs",
"Contact us" — whatever the organisation actually offers.
## Trust data
Highlights, licence, accreditation, founding line and rating render only when
passed, as rounded chips under the actions. None has a default. A rating must
be a number from 0 to 5; anything else is ignored. The review count and
source are optional and never assumed.
## Media
- **With `image`:** the photo fills the pebble frame, `object-fit: cover`.
Give it an `alt` unless it is purely decorative.
- **Without:** the frame holds the drawn scene (a sun circle and two hill
shapes), marked `aria-hidden`. There is no placeholder text and no demo
photo: the component ships no imagery of people.
## Responsive behaviour
- Stacked below 900px: copy first, frame after it, centred and capped at
520px.
- Two columns (1.1fr / 1fr) from 900px, frame on the right.
## Motion
On load the copy and the frame rise 12px and fade in over 0.7s, the frame
0.12s later. CSS animations that end at rest, so nothing is hidden without
them; removed under `prefers-reduced-motion: reduce`, as are the button colour
transitions. `animate={false}` turns the settle-in off.
## Accessibility
- One `<h1>` by default.
- Buttons are real links with a 3px focus outline.
- Highlights and trust chips are lists; decorative shapes are `aria-hidden`.
## Tokens
`--pfh-bg`, `--pfh-text`, `--pfh-text-muted`, `--pfh-brand`,
`--pfh-brand-text`, `--pfh-accent-1` (sage), `--pfh-accent-2` (sun),
`--pfh-accent-3` (sky), `--pfh-family`.
## Selection
Friendly and professional in tone, subtle motion, direct pressure. No market
is recorded: nothing in it is specific to one industry, and its warmth is a
tone rather than a market. It needs no client material; a photo is an
alternate presentation.
===== END src/components/PebbleFrameHero.md =====
--- 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: not applicable — this component ships no script.
--- 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.