Component librarylocal catalog
Back to catalog

Framed hero

A full-viewport hero inside a rounded panel, with a scrimmed backdrop and a headline that rises line by line.

heromediaanimation

Open preview in a new tab

Use this component

Three ways to get Framed hero into one of your own projects, all pre-filled with this component's name and library page.

Start here

Component reference

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

Read it first
“Framed hero” from my Astro Component Library:
https://astro.baysixmedia.com/components/framed-hero/

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

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

ChatGPT brief

Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.

Read it first
Help me add this component to one of my Astro websites:

Component: Framed hero
Library page: https://astro.baysixmedia.com/components/framed-hero

I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]

Place it:
[ENTER LOCATION OR SELECTOR]

Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]

Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.

Placement prompt

Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.

Read it first
Add Framed hero from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/framed-hero

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

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

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

src/components/library/FramedHero.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/framed-hero/backdrop-1440.webp, public/framed-hero/backdrop-1920.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * 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 {
	brand = "Northbeam",
	heading = "Interfaces built\nto outlast the launch.",
	subtitle = "We design and build the parts people touch, then hand over a system your team can keep changing without asking us first.",
	kicker = "Studio practice · Systems over screens",
	actions = [
		{ label: "Start a project", href: "mailto:studio@northbeam.example", variant: "solid" },
		/* No destination in the demo, so this one renders as a static pill
		   rather than a button that looks live and does nothing. */
		{ label: "See the work", variant: "outline" },
	],
	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 !== "#";
};

/* Index the copy blocks so each can follow the last without hard-coding. */
const copyDelay = (step: number) => `${lines.length * 110 + step * 90}ms`;
---

<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>
					)
				}

				{
					actions.length > 0 && (
						<div class="actions" data-lift style={`--d:${copyDelay(2)}`}>
							{actions.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>