A full-width hero section: a background image behind a headline, subtext and call-to-action links, aligned left, centre or right.
registry/behivetech/organisms/hero-banner at v0.8.3, snapshotted 2026-11-09
hero-banner.meta.ts
1import type { ComponentMeta } from "@behivetech/cms.types";23// The CMS admin never imports this package at runtime — it reads `meta` and4// `schema` at catalog-seed time and stores a snapshot. So this is the single5// 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 what9// 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";23/**4 * Prop migrations for stored hero-banner blocks.5 *6 * This is a real migration, not a demonstration. 0.4.0 replaced the single7 * `ctaLabel`/`ctaHref` pair with `ctaButtons`, an array. The component still8 * reads the old pair so nothing broke on release, but that fallback is a9 * 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>;2425 // Only build a button when there is actually one to build. A block that26 // never had a CTA must not gain an empty row that shows up in the editor27 // as a half-filled button the author never created.28 const hasCta =29 typeof ctaLabel === "string" &&30 ctaLabel !== "" &&31 typeof ctaHref === "string" &&32 ctaHref !== "";3334 if (!hasCta) return rest;3536 // Not overwritten if `ctaButtons` is already populated: a block could have37 // been edited after the upgrade, in which case the new field is the38 // 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;4142 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";23// Field order here is the order the CMS prop panel renders inputs in, so it is4// 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 for8// authoring. The component still reads the old pair, so pages written before9// this change keep rendering — but the panel should not offer two ways to set10// the same thing, and it is the schema, not the props interface, that decides11// 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";45/** 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}1415export 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}3536/**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 the41 * `--hero-banner-scrim-opacity` custom property (default `0.4`). A site whose42 * editors pick any photo sets it on its own class, e.g. `0.6` to keep the white43 * 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 });6263 // `ctaButtons` arrives from a jsonb column an author edits through the CMS, so64 // it can legitimately hold half-filled rows — someone adds a button, types the65 // label, and saves before pasting the URL. Those are skipped rather than66 // rendered as a dead link. Falling back to ctaLabel/ctaHref keeps pages67 // authored before this prop existed rendering unchanged.68 const ctas: HeroBannerCta[] = (69 Array.isArray(ctaButtons) && ctaButtons.length > 070 ? ctaButtons71 : [{ 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 );7677 return (78 <div79 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 <a89 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 must4// stay light in both color schemes. MD3 has no "on-scrim" role, and every5// `on-*` role flips with the theme, so the one light literal lives in6// `--hero-banner-on-image` below rather than being repeated per rule. A site7// 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 the10// scrim darkens the image. A site whose editors choose any photo raises it on11// its own class (0.6 keeps white text above 4.5:1 over a white photo); the12// default leaves every existing use unchanged.1314.BHT__HeroBanner {15 --hero-banner-on-image: #ffffff;1617 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);2526 // Scrim over the image. Opacity rather than a translucent color so it stays27 // 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 }3536 &__content {37 position: relative;38 z-index: 1;39 max-width: 45rem;40 width: 100%;41 margin: 0 auto;42 text-align: center;43 }4445 &__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 }5758 &__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 }6768 &__ctas {69 display: flex;70 flex-wrap: wrap;71 gap: var(--spacing-3, 0.75rem);72 justify-content: center;73 }7475 // 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;9192 &::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 }103104 &:hover::after {105 opacity: var(--md-sys-state-hover-opacity, 0.08);106 }107108 &:focus-visible::after {109 opacity: var(--md-sys-state-focus-opacity, 0.1);110 }111112 &:active::after {113 opacity: var(--md-sys-state-pressed-opacity, 0.1);114 }115116 // Touch target, as in Button: extends the hit area to 48px without117 // 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 }128129 &:focus-visible {130 outline: 3px solid131 var(--md-sys-color-secondary, var(--color-border-focus));132 outline-offset: 2px;133 }134 }135136 &__cta-secondary {137 background: transparent;138 color: var(--hero-banner-on-image);139 box-shadow: inset 0 0 0 1px140 color-mix(in srgb, var(--hero-banner-on-image) 70%, transparent);141 }142143 &--align-left {144 .BHT__HeroBanner {145 &__content {146 text-align: left;147 margin-left: 0;148 }149150 &__ctas {151 justify-content: flex-start;152 }153 }154 }155156 &--align-right {157 .BHT__HeroBanner {158 &__content {159 text-align: right;160 margin-right: 0;161 }162163 &__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 seed4// 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";23export const BasicHeroBanner = () => (4 <HeroBanner>HeroBanner content</HeroBanner>5);67export const HeroBannerWithButtons = () => (8 <HeroBanner9 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);