A full-width hero section: a background image behind a headline, subtext and call-to-action links, aligned left, centre or right.

pnpm add @behivetech/organisms.hero-banner

You are viewing Hero Banner v0.8.3, snapshotted 2026-11-09. The latest is v0.8.4. Go to the latest →

registry/behivetech/organisms/hero-banner at v0.8.3, snapshotted 2026-11-09

hero-banner.meta.ts
1import type { ComponentMeta } from "@behivetech/cms.types";
2
3// The CMS admin never imports this package at runtime — it reads `meta` and
4// `schema` at catalog-seed time and stores a snapshot. So this is the single
5// source of truth for how the component presents itself in the block picker;
6// anything restated in the admin would drift from what the package ships.
7//
8// `name` is the registry key: it is what lands in `Block.component`, and what
9// a consuming site must use as its key in `src/cms/registry.ts`.
10export const heroBannerMeta: ComponentMeta = {
11 name: "HeroBanner",
12 displayName: "Hero Banner",
13 description:
14 "Full-width hero section with headline, subtext, and call-to-action",
15 icon: "🎯",
16 tags: ["marketing", "layout"],
17 acceptsChildren: false,
18};
hero-banner.migrations.ts
1import type { Migration } from "@behivetech/cms.types";
2
3/**
4 * Prop migrations for stored hero-banner blocks.
5 *
6 * This is a real migration, not a demonstration. 0.4.0 replaced the single
7 * `ctaLabel`/`ctaHref` pair with `ctaButtons`, an array. The component still
8 * reads the old pair so nothing broke on release, but that fallback is a
9 * permanent tax on the component: two code paths for one feature, forever.
10 * Migrating the stored content is what eventually lets it go.
11 *
12 * Chain order is expressed by `fromVersion`/`toVersion`, not array position —
13 * the runner follows the links.
14 */
15export const heroBannerMigrations: Migration[] = [
16 {
17 fromVersion: "0.3.1",
18 toVersion: "0.4.0",
19 migrate(props) {
20 const { ctaLabel, ctaHref, ...rest } = props as {
21 ctaLabel?: unknown;
22 ctaHref?: unknown;
23 } & Record<string, unknown>;
24
25 // Only build a button when there is actually one to build. A block that
26 // never had a CTA must not gain an empty row that shows up in the editor
27 // as a half-filled button the author never created.
28 const hasCta =
29 typeof ctaLabel === "string" &&
30 ctaLabel !== "" &&
31 typeof ctaHref === "string" &&
32 ctaHref !== "";
33
34 if (!hasCta) return rest;
35
36 // Not overwritten if `ctaButtons` is already populated: a block could have
37 // been edited after the upgrade, in which case the new field is the
38 // author's intent and the stale pair is the thing to drop.
39 const existing = rest.ctaButtons;
40 if (Array.isArray(existing) && existing.length > 0) return rest;
41
42 return {
43 ...rest,
44 ctaButtons: [{ label: ctaLabel, href: ctaHref, variant: "primary" }],
45 };
46 },
47 },
48];
hero-banner.schema.ts
1import type { ComponentSchema } from "@behivetech/cms.types";
2
3// Field order here is the order the CMS prop panel renders inputs in, so it is
4// deliberate rather than incidental: headline first, then supporting content,
5// then the call to action, then presentation.
6//
7// `ctaLabel` / `ctaHref` are deliberately absent: `ctaButtons` replaces them for
8// authoring. The component still reads the old pair, so pages written before
9// this change keep rendering — but the panel should not offer two ways to set
10// the same thing, and it is the schema, not the props interface, that decides
11// what an author sees.
12export const heroBannerSchema: ComponentSchema = {
13 fields: [
14 {
15 name: "headline",
16 type: "string",
17 label: "Headline",
18 placeholder: "Enter headline…",
19 required: true,
20 defaultValue: "Welcome",
21 },
22 {
23 name: "subtext",
24 type: "richtext",
25 label: "Subtext",
26 placeholder: "Supporting text…",
27 defaultValue: "",
28 },
29 {
30 name: "imageUrl",
31 type: "image",
32 label: "Background Image",
33 defaultValue: "",
34 },
35 {
36 name: "ctaButtons",
37 type: "array",
38 label: "Buttons",
39 hint: "The first button is the main action. Leave empty for a banner with no call to action.",
40 defaultValue: [],
41 itemSchema: [
42 {
43 name: "label",
44 type: "string",
45 label: "Label",
46 placeholder: "Get started",
47 required: true,
48 },
49 {
50 name: "href",
51 type: "url",
52 label: "Link",
53 placeholder: "https://…",
54 required: true,
55 },
56 {
57 name: "variant",
58 type: "select",
59 label: "Style",
60 defaultValue: "primary",
61 options: [
62 { label: "Primary", value: "primary" },
63 { label: "Secondary", value: "secondary" },
64 ],
65 },
66 ],
67 },
68 {
69 name: "align",
70 type: "select",
71 label: "Text Alignment",
72 defaultValue: "center",
73 options: [
74 { label: "Left", value: "left" },
75 { label: "Center", value: "center" },
76 { label: "Right", value: "right" },
77 ],
78 },
79 ],
80};
hero-banner.tsx
1import { type ReactNode } from "react";
2import { getClassName } from "@bht-component-registry/class-names";
3import styles from "./hero-banner.module.scss";
4
5/** One call-to-action link in `ctaButtons`. */
6export interface HeroBannerCta {
7 /** Link text; a button with no label is skipped */
8 label?: string;
9 /** Destination URL; a button with no href is skipped */
10 href?: string;
11 /** Visual weight — the first button usually leads, the rest support */
12 variant?: "primary" | "secondary";
13}
14
15export interface HeroBannerProps {
16 /** Main heading text */
17 headline?: string;
18 /** Supporting text shown below the headline */
19 subtext?: string;
20 /** Background image URL rendered behind the content */
21 imageUrl?: string;
22 /** Label for the call-to-action link; requires `ctaHref` to render. Superseded by `ctaButtons`, kept so existing content keeps rendering */
23 ctaLabel?: string;
24 /** Destination URL for the call-to-action link; requires `ctaLabel` to render. Superseded by `ctaButtons` */
25 ctaHref?: string;
26 /** Call-to-action links. When present these render instead of `ctaLabel`/`ctaHref` */
27 ctaButtons?: HeroBannerCta[];
28 /** Horizontal alignment of the content; defaults to center */
29 align?: "left" | "center" | "right";
30 /** Additional class names to merge with the component root element */
31 className?: string;
32 /** Additional content rendered after the CTA */
33 children?: ReactNode;
34}
35
36/**
37 * A full-width hero: a background image under a scrim, with a headline,
38 * subtext and call-to-action links on top.
39 *
40 * **Styling hook:** the scrim's darkness is read from the
41 * `--hero-banner-scrim-opacity` custom property (default `0.4`). A site whose
42 * editors pick any photo sets it on its own class, e.g. `0.6` to keep the white
43 * text above 4.5:1 over a pale photo.
44 */
45export const HeroBanner = ({
46 headline,
47 subtext,
48 imageUrl,
49 ctaLabel,
50 ctaHref,
51 ctaButtons,
52 align = "center",
53 className,
54 children,
55}: HeroBannerProps) => {
56 const [rootClass, getChildClass] = getClassName({
57 className,
58 rootClass: "BHT__HeroBanner",
59 modifiers: { [`align-${align}`]: true },
60 styles,
61 });
62
63 // `ctaButtons` arrives from a jsonb column an author edits through the CMS, so
64 // it can legitimately hold half-filled rows — someone adds a button, types the
65 // label, and saves before pasting the URL. Those are skipped rather than
66 // rendered as a dead link. Falling back to ctaLabel/ctaHref keeps pages
67 // authored before this prop existed rendering unchanged.
68 const ctas: HeroBannerCta[] = (
69 Array.isArray(ctaButtons) && ctaButtons.length > 0
70 ? ctaButtons
71 : [{ label: ctaLabel, href: ctaHref, variant: "primary" as const }]
72 ).filter(
73 (cta): cta is HeroBannerCta =>
74 Boolean(cta) && Boolean(cta.label) && Boolean(cta.href),
75 );
76
77 return (
78 <div
79 className={rootClass}
80 style={imageUrl ? { backgroundImage: `url(${imageUrl})` } : undefined}
81 >
82 <div className={getChildClass("content")}>
83 {headline && <h1 className={getChildClass("headline")}>{headline}</h1>}
84 {subtext && <p className={getChildClass("subtext")}>{subtext}</p>}
85 {ctas.length > 0 && (
86 <div className={getChildClass("ctas")}>
87 {ctas.map((cta, index) => (
88 <a
89 key={`${cta.href}-${index}`}
90 className={`${getChildClass("cta")} ${
91 cta.variant === "secondary"
92 ? getChildClass("cta-secondary")
93 : ""
94 }`.trim()}
95 href={cta.href}
96 >
97 {cta.label}
98 </a>
99 ))}
100 </div>
101 )}
102 {children}
103 </div>
104 </div>
105 );
106};
hero-banner.module.scss
1// Hero banner: full-bleed image under a scrim, with content on top.
2//
3// The text sits on a darkened photograph, not on a theme surface, so it must
4// stay light in both color schemes. MD3 has no "on-scrim" role, and every
5// `on-*` role flips with the theme, so the one light literal lives in
6// `--hero-banner-on-image` below rather than being repeated per rule. A site
7// can override it on the root if its imagery calls for something else.
8//
9// Styling hook: `--hero-banner-scrim-opacity` (default 0.4) sets how far the
10// scrim darkens the image. A site whose editors choose any photo raises it on
11// its own class (0.6 keeps white text above 4.5:1 over a white photo); the
12// default leaves every existing use unchanged.
13
14.BHT__HeroBanner {
15 --hero-banner-on-image: #ffffff;
16
17 position: relative;
18 min-height: 30rem;
19 display: flex;
20 align-items: center;
21 background-size: cover;
22 background-position: center;
23 background-color: var(--md-sys-color-surface, var(--color-surface));
24 padding: var(--spacing-16, 4rem) var(--spacing-8, 2rem);
25
26 // Scrim over the image. Opacity rather than a translucent color so it stays
27 // on the `scrim` role; the fallback matches the previous 40% darkening exactly.
28 &::before {
29 content: "";
30 position: absolute;
31 inset: 0;
32 background: var(--md-sys-color-scrim, var(--color-overlay));
33 opacity: var(--hero-banner-scrim-opacity, 0.4);
34 }
35
36 &__content {
37 position: relative;
38 z-index: 1;
39 max-width: 45rem;
40 width: 100%;
41 margin: 0 auto;
42 text-align: center;
43 }
44
45 &__headline {
46 font-family: var(
47 --md-sys-typescale-display-medium-font,
48 var(--font-heading)
49 );
50 font-size: var(--md-sys-typescale-display-medium-size, 2.8125rem);
51 font-weight: var(--md-sys-typescale-display-medium-weight, 400);
52 line-height: var(--md-sys-typescale-display-medium-line-height, 3.25rem);
53 letter-spacing: var(--md-sys-typescale-display-medium-tracking, 0em);
54 color: var(--hero-banner-on-image);
55 margin: 0 0 var(--spacing-4, 1rem);
56 }
57
58 &__subtext {
59 font-family: var(--md-sys-typescale-title-large-font, var(--font-body));
60 font-size: var(--md-sys-typescale-title-large-size, 1.375rem);
61 font-weight: var(--md-sys-typescale-title-large-weight, 400);
62 line-height: var(--md-sys-typescale-title-large-line-height, 1.75rem);
63 letter-spacing: var(--md-sys-typescale-title-large-tracking, 0em);
64 color: color-mix(in srgb, var(--hero-banner-on-image) 85%, transparent);
65 margin: 0 0 var(--spacing-6, 1.5rem);
66 }
67
68 &__ctas {
69 display: flex;
70 flex-wrap: wrap;
71 gap: var(--spacing-3, 0.75rem);
72 justify-content: center;
73 }
74
75 // CTAs follow Button's large filled / outlined treatment: pill shape,
76 // label-large type at title-medium size, and a currentColor state layer.
77 &__cta {
78 position: relative;
79 isolation: isolate;
80 display: inline-block;
81 padding: var(--spacing-3, 0.75rem) var(--spacing-8, 2rem);
82 background: var(--md-sys-color-primary, var(--color-primary));
83 color: var(--md-sys-color-on-primary, var(--color-on-primary));
84 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
85 font-size: var(--md-sys-typescale-title-medium-size, 1rem);
86 font-weight: var(--md-sys-typescale-label-large-weight, 500);
87 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);
88 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
89 border-radius: var(--md-sys-shape-corner-full, 9999px);
90 text-decoration: none;
91
92 &::after {
93 content: "";
94 position: absolute;
95 inset: 0;
96 border-radius: inherit;
97 background: currentColor;
98 opacity: 0;
99 pointer-events: none;
100 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
101 var(--md-sys-motion-easing-standard, ease);
102 }
103
104 &:hover::after {
105 opacity: var(--md-sys-state-hover-opacity, 0.08);
106 }
107
108 &:focus-visible::after {
109 opacity: var(--md-sys-state-focus-opacity, 0.1);
110 }
111
112 &:active::after {
113 opacity: var(--md-sys-state-pressed-opacity, 0.1);
114 }
115
116 // Touch target, as in Button: extends the hit area to 48px without
117 // changing the drawn size. px because it is sized to the finger.
118 &::before {
119 content: "";
120 position: absolute;
121 left: 0;
122 right: 0;
123 top: 50%;
124 translate: 0 -50%;
125 height: 100%;
126 min-height: 48px;
127 }
128
129 &:focus-visible {
130 outline: 3px solid
131 var(--md-sys-color-secondary, var(--color-border-focus));
132 outline-offset: 2px;
133 }
134 }
135
136 &__cta-secondary {
137 background: transparent;
138 color: var(--hero-banner-on-image);
139 box-shadow: inset 0 0 0 1px
140 color-mix(in srgb, var(--hero-banner-on-image) 70%, transparent);
141 }
142
143 &--align-left {
144 .BHT__HeroBanner {
145 &__content {
146 text-align: left;
147 margin-left: 0;
148 }
149
150 &__ctas {
151 justify-content: flex-start;
152 }
153 }
154 }
155
156 &--align-right {
157 .BHT__HeroBanner {
158 &__content {
159 text-align: right;
160 margin-right: 0;
161 }
162
163 &__ctas {
164 justify-content: flex-end;
165 }
166 }
167 }
168}
index.ts
1export { HeroBanner } from "./hero-banner.js";
2export type { HeroBannerProps, HeroBannerCta } from "./hero-banner.js";
3// `schema` and `meta` aliases are the stable contract the CMS catalog seed
4// reads by; the prefixed names stay for direct consumers.
5export {
6 heroBannerSchema,
7 heroBannerSchema as schema,
8} from "./hero-banner.schema.js";
9export { heroBannerMeta, heroBannerMeta as meta } from "./hero-banner.meta.js";
10export {
11 heroBannerMigrations,
12 heroBannerMigrations as migrations,
13} from "./hero-banner.migrations.js";
hero-banner.example.tsx
1import { HeroBanner } from "./hero-banner.js";
2
3export const BasicHeroBanner = () => (
4 <HeroBanner>HeroBanner content</HeroBanner>
5);
6
7export const HeroBannerWithButtons = () => (
8 <HeroBanner
9 headline="Build it once"
10 subtext="Ship the same components to every site you run."
11 ctaButtons={[
12 { label: "Get started", href: "https://example.com" },
13 {
14 label: "Read the docs",
15 href: "https://docs.example.com",
16 variant: "secondary",
17 },
18 ]}
19 />
20);