Behive Tech Registry

Hero Banner

v0.7.0

TODO: Add component description.

pnpm add @behivetech/organisms.hero-banner

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";
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
36export 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 });
53
54 // `ctaButtons` arrives from a jsonb column an author edits through the CMS, so
55 // it can legitimately hold half-filled rows — someone adds a button, types the
56 // label, and saves before pasting the URL. Those are skipped rather than
57 // rendered as a dead link. Falling back to ctaLabel/ctaHref keeps pages
58 // authored before this prop existed rendering unchanged.
59 const ctas: HeroBannerCta[] = (
60 Array.isArray(ctaButtons) && ctaButtons.length > 0
61 ? ctaButtons
62 : [{ 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 );
67
68 return (
69 <div
70 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 <a
80 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 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.BHT__hero-banner {
10 --hero-banner-on-image: #ffffff;
11
12 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);
20
21 // Scrim over the image. Opacity rather than a translucent color so it stays
22 // 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 }
30
31 &__content {
32 position: relative;
33 z-index: 1;
34 max-width: 45rem;
35 width: 100%;
36 margin: 0 auto;
37 text-align: center;
38 }
39
40 &__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 }
52
53 &__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 }
62
63 &__ctas {
64 display: flex;
65 flex-wrap: wrap;
66 gap: var(--spacing-3, 0.75rem);
67 justify-content: center;
68 }
69
70 // 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;
86
87 &::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 }
98
99 &:hover::after {
100 opacity: var(--md-sys-state-hover-opacity, 0.08);
101 }
102
103 &:focus-visible::after {
104 opacity: var(--md-sys-state-focus-opacity, 0.1);
105 }
106
107 &:active::after {
108 opacity: var(--md-sys-state-pressed-opacity, 0.1);
109 }
110
111 // Touch target, as in Button: extends the hit area to 48px without
112 // 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 }
123
124 &:focus-visible {
125 outline: 3px solid
126 var(--md-sys-color-secondary, var(--color-border-focus));
127 outline-offset: 2px;
128 }
129 }
130
131 &__cta-secondary {
132 background: transparent;
133 color: var(--hero-banner-on-image);
134 box-shadow: inset 0 0 0 1px
135 color-mix(in srgb, var(--hero-banner-on-image) 70%, transparent);
136 }
137
138 &--align-left {
139 .BHT__hero-banner {
140 &__content {
141 text-align: left;
142 margin-left: 0;
143 }
144
145 &__ctas {
146 justify-content: flex-start;
147 }
148 }
149 }
150
151 &--align-right {
152 .BHT__hero-banner {
153 &__content {
154 text-align: right;
155 margin-right: 0;
156 }
157
158 &__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";
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);
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";
5
6describe("HeroBanner", () => {
7 it("renders headline", () => {
8 render(<HeroBanner headline="Welcome" />);
9 expect(screen.getByRole("heading", { name: "Welcome" })).toBeTruthy();
10 });
11
12 it("renders subtext", () => {
13 render(<HeroBanner subtext="Supporting text" />);
14 expect(screen.getByText("Supporting text")).toBeTruthy();
15 });
16
17 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 });
22
23 it("renders every entry in ctaButtons", () => {
24 render(
25 <HeroBanner
26 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 });
43
44 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 the
46 // URL. That row is stored as-is, so the component has to cope with it.
47 render(
48 <HeroBanner
49 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 });
58
59 it("still renders ctaLabel/ctaHref content written before ctaButtons existed", () => {
60 // The schema no longer offers these fields, but stored pages still carry
61 // them. Dropping the fallback would silently blank the CTA on every hero
62 // banner already published.
63 render(
64 <HeroBanner
65 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 });
74
75 it("prefers ctaButtons over the legacy pair when both are present", () => {
76 render(
77 <HeroBanner
78 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 });
86
87 it("renders children", () => {
88 render(<HeroBanner>Hello</HeroBanner>);
89 expect(screen.getByText("Hello")).toBeTruthy();
90 });
91
92 it("applies additional className to root element", () => {
93 const { container } = render(<HeroBanner className="custom" />);
94 expect(container.firstElementChild?.className).toContain("custom");
95 });
96
97 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 });
103
104 /**
105 * `getChildClass("x")` looks up `BHT__hero-banner__x` and silently falls back
106 * to that literal string when the stylesheet has no such rule. This
107 * stylesheet used to declare flat `.content` / `.headline` / `.cta`, so every
108 * child class resolved to a selector that did not exist and none of the child
109 * styling applied. Nothing at runtime catches that — vitest stubs CSS
110 * modules, so the fallback and a real hit are indistinguishable — which is
111 * 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");
117
118 const used = [...tsx.matchAll(/getChildClass\("([a-z-]+)"\)/g)].map(
119 (m) => m[1],
120 );
121 expect(used.length).toBeGreaterThan(0);
122
123 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});