Hero Banner
v0.7.0TODO: Add component description.
registry/behivetech/organisms/hero-banner · depends on @behivetech/get-class-name
hero-banner.tsx
1import { type ReactNode } from "react";2import { getClassName } from "@behivetech/get-class-name";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}3536export const HeroBanner = ({37 headline,38 subtext,39 imageUrl,40 ctaLabel,41 ctaHref,42 ctaButtons,43 align = "center",44 className,45 children,46}: HeroBannerProps) => {47 const [rootClass, getChildClass] = getClassName({48 className,49 rootClass: "BHT__hero-banner",50 modifiers: { [`align-${align}`]: true },51 styles,52 });5354 // `ctaButtons` arrives from a jsonb column an author edits through the CMS, so55 // it can legitimately hold half-filled rows — someone adds a button, types the56 // label, and saves before pasting the URL. Those are skipped rather than57 // rendered as a dead link. Falling back to ctaLabel/ctaHref keeps pages58 // authored before this prop existed rendering unchanged.59 const ctas: HeroBannerCta[] = (60 Array.isArray(ctaButtons) && ctaButtons.length > 061 ? ctaButtons62 : [{ label: ctaLabel, href: ctaHref, variant: "primary" as const }]63 ).filter(64 (cta): cta is HeroBannerCta =>65 Boolean(cta) && Boolean(cta.label) && Boolean(cta.href),66 );6768 return (69 <div70 className={rootClass}71 style={imageUrl ? { backgroundImage: `url(${imageUrl})` } : undefined}72 >73 <div className={getChildClass("content")}>74 {headline && <h1 className={getChildClass("headline")}>{headline}</h1>}75 {subtext && <p className={getChildClass("subtext")}>{subtext}</p>}76 {ctas.length > 0 && (77 <div className={getChildClass("ctas")}>78 {ctas.map((cta, index) => (79 <a80 key={`${cta.href}-${index}`}81 className={`${getChildClass("cta")} ${82 cta.variant === "secondary"83 ? getChildClass("cta-secondary")84 : ""85 }`.trim()}86 href={cta.href}87 >88 {cta.label}89 </a>90 ))}91 </div>92 )}93 {children}94 </div>95 </div>96 );97};
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.89.BHT__hero-banner {10 --hero-banner-on-image: #ffffff;1112 position: relative;13 min-height: 30rem;14 display: flex;15 align-items: center;16 background-size: cover;17 background-position: center;18 background-color: var(--md-sys-color-surface, var(--color-surface));19 padding: var(--spacing-16, 4rem) var(--spacing-8, 2rem);2021 // Scrim over the image. Opacity rather than a translucent color so it stays22 // on the `scrim` role and matches the previous 40% darkening exactly.23 &::before {24 content: "";25 position: absolute;26 inset: 0;27 background: var(--md-sys-color-scrim, var(--color-overlay));28 opacity: 0.4;29 }3031 &__content {32 position: relative;33 z-index: 1;34 max-width: 45rem;35 width: 100%;36 margin: 0 auto;37 text-align: center;38 }3940 &__headline {41 font-family: var(42 --md-sys-typescale-display-medium-font,43 var(--font-heading)44 );45 font-size: var(--md-sys-typescale-display-medium-size, 2.8125rem);46 font-weight: var(--md-sys-typescale-display-medium-weight, 400);47 line-height: var(--md-sys-typescale-display-medium-line-height, 3.25rem);48 letter-spacing: var(--md-sys-typescale-display-medium-tracking, 0em);49 color: var(--hero-banner-on-image);50 margin: 0 0 var(--spacing-4, 1rem);51 }5253 &__subtext {54 font-family: var(--md-sys-typescale-title-large-font, var(--font-body));55 font-size: var(--md-sys-typescale-title-large-size, 1.375rem);56 font-weight: var(--md-sys-typescale-title-large-weight, 400);57 line-height: var(--md-sys-typescale-title-large-line-height, 1.75rem);58 letter-spacing: var(--md-sys-typescale-title-large-tracking, 0em);59 color: color-mix(in srgb, var(--hero-banner-on-image) 85%, transparent);60 margin: 0 0 var(--spacing-6, 1.5rem);61 }6263 &__ctas {64 display: flex;65 flex-wrap: wrap;66 gap: var(--spacing-3, 0.75rem);67 justify-content: center;68 }6970 // CTAs follow Button's large filled / outlined treatment: pill shape,71 // label-large type at title-medium size, and a currentColor state layer.72 &__cta {73 position: relative;74 isolation: isolate;75 display: inline-block;76 padding: var(--spacing-3, 0.75rem) var(--spacing-8, 2rem);77 background: var(--md-sys-color-primary, var(--color-primary));78 color: var(--md-sys-color-on-primary, var(--color-on-primary));79 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));80 font-size: var(--md-sys-typescale-title-medium-size, 1rem);81 font-weight: var(--md-sys-typescale-label-large-weight, 500);82 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);83 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);84 border-radius: var(--md-sys-shape-corner-full, 9999px);85 text-decoration: none;8687 &::after {88 content: "";89 position: absolute;90 inset: 0;91 border-radius: inherit;92 background: currentColor;93 opacity: 0;94 pointer-events: none;95 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)96 var(--md-sys-motion-easing-standard, ease);97 }9899 &:hover::after {100 opacity: var(--md-sys-state-hover-opacity, 0.08);101 }102103 &:focus-visible::after {104 opacity: var(--md-sys-state-focus-opacity, 0.1);105 }106107 &:active::after {108 opacity: var(--md-sys-state-pressed-opacity, 0.1);109 }110111 // Touch target, as in Button: extends the hit area to 48px without112 // changing the drawn size. px because it is sized to the finger.113 &::before {114 content: "";115 position: absolute;116 left: 0;117 right: 0;118 top: 50%;119 translate: 0 -50%;120 height: 100%;121 min-height: 48px;122 }123124 &:focus-visible {125 outline: 3px solid126 var(--md-sys-color-secondary, var(--color-border-focus));127 outline-offset: 2px;128 }129 }130131 &__cta-secondary {132 background: transparent;133 color: var(--hero-banner-on-image);134 box-shadow: inset 0 0 0 1px135 color-mix(in srgb, var(--hero-banner-on-image) 70%, transparent);136 }137138 &--align-left {139 .BHT__hero-banner {140 &__content {141 text-align: left;142 margin-left: 0;143 }144145 &__ctas {146 justify-content: flex-start;147 }148 }149 }150151 &--align-right {152 .BHT__hero-banner {153 &__content {154 text-align: right;155 margin-right: 0;156 }157158 &__ctas {159 justify-content: flex-end;160 }161 }162 }163}
index.ts
1export { HeroBanner } from "./hero-banner.js";2export type { HeroBannerProps, HeroBannerCta } from "./hero-banner.js";
hero-banner.composition.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);
hero-banner.spec.tsx
1import { readFileSync } from "node:fs";2import { join } from "node:path";3import { render, screen } from "@testing-library/react";4import { HeroBanner } from "./hero-banner.js";56describe("HeroBanner", () => {7 it("renders headline", () => {8 render(<HeroBanner headline="Welcome" />);9 expect(screen.getByRole("heading", { name: "Welcome" })).toBeTruthy();10 });1112 it("renders subtext", () => {13 render(<HeroBanner subtext="Supporting text" />);14 expect(screen.getByText("Supporting text")).toBeTruthy();15 });1617 it("renders CTA link when both ctaLabel and ctaHref are provided", () => {18 render(<HeroBanner ctaLabel="Get started" ctaHref="https://example.com" />);19 const link = screen.getByRole("link", { name: "Get started" });20 expect(link.getAttribute("href")).toBe("https://example.com");21 });2223 it("renders every entry in ctaButtons", () => {24 render(25 <HeroBanner26 ctaButtons={[27 { label: "Get started", href: "https://example.com" },28 {29 label: "Read the docs",30 href: "https://docs.example.com",31 variant: "secondary",32 },33 ]}34 />,35 );36 expect(37 screen.getByRole("link", { name: "Get started" }).getAttribute("href"),38 ).toBe("https://example.com");39 expect(40 screen.getByRole("link", { name: "Read the docs" }).getAttribute("href"),41 ).toBe("https://docs.example.com");42 });4344 it("skips a half-filled button rather than rendering a dead link", () => {45 // An author adds a button, types the label, and saves before pasting the46 // URL. That row is stored as-is, so the component has to cope with it.47 render(48 <HeroBanner49 ctaButtons={[50 { label: "Get started", href: "https://example.com" },51 { label: "Not finished yet" },52 ]}53 />,54 );55 expect(screen.getAllByRole("link")).toHaveLength(1);56 expect(screen.queryByText("Not finished yet")).toBeNull();57 });5859 it("still renders ctaLabel/ctaHref content written before ctaButtons existed", () => {60 // The schema no longer offers these fields, but stored pages still carry61 // them. Dropping the fallback would silently blank the CTA on every hero62 // banner already published.63 render(64 <HeroBanner65 ctaLabel="Legacy button"66 ctaHref="https://legacy.example.com"67 ctaButtons={[]}68 />,69 );70 expect(71 screen.getByRole("link", { name: "Legacy button" }).getAttribute("href"),72 ).toBe("https://legacy.example.com");73 });7475 it("prefers ctaButtons over the legacy pair when both are present", () => {76 render(77 <HeroBanner78 ctaLabel="Legacy button"79 ctaHref="https://legacy.example.com"80 ctaButtons={[{ label: "New button", href: "https://example.com" }]}81 />,82 );83 expect(screen.getByRole("link", { name: "New button" })).toBeTruthy();84 expect(screen.queryByText("Legacy button")).toBeNull();85 });8687 it("renders children", () => {88 render(<HeroBanner>Hello</HeroBanner>);89 expect(screen.getByText("Hello")).toBeTruthy();90 });9192 it("applies additional className to root element", () => {93 const { container } = render(<HeroBanner className="custom" />);94 expect(container.firstElementChild?.className).toContain("custom");95 });9697 it("applies the align modifier to the root element", () => {98 const { container } = render(<HeroBanner align="left" />);99 expect(container.firstElementChild?.className).toContain(100 "BHT__hero-banner--align-left",101 );102 });103104 /**105 * `getChildClass("x")` looks up `BHT__hero-banner__x` and silently falls back106 * to that literal string when the stylesheet has no such rule. This107 * stylesheet used to declare flat `.content` / `.headline` / `.cta`, so every108 * child class resolved to a selector that did not exist and none of the child109 * styling applied. Nothing at runtime catches that — vitest stubs CSS110 * modules, so the fallback and a real hit are indistinguishable — which is111 * why this asserts against the source instead.112 */113 it("declares a rule for every child class the component renders", () => {114 const dir = import.meta.dirname;115 const tsx = readFileSync(join(dir, "hero-banner.tsx"), "utf-8");116 const scss = readFileSync(join(dir, "hero-banner.module.scss"), "utf-8");117118 const used = [...tsx.matchAll(/getChildClass\("([a-z-]+)"\)/g)].map(119 (m) => m[1],120 );121 expect(used.length).toBeGreaterThan(0);122123 const declared = new Set(124 [...scss.matchAll(/&__([a-z-]+)\s*\{/g)].map((m) => m[1]),125 );126 expect([...new Set(used)].filter((n) => !declared.has(n))).toEqual([]);127 });128});