A card with an image on top and a title, subtitle and meta row below, for artwork lists, artist tiles and booking previews. It composes `atoms.card` with no padding, so fill, border and corners are Card's.

pnpm add @behivetech/molecules.image-card

You are viewing Image Card v0.2.0, snapshotted 2026-11-09. The latest is v0.2.2. Go to the latest →

registry/behivetech/molecules/image-card at v0.2.0, snapshotted 2026-11-09

image-card-media.tsx
1import { isValidElement, type ReactNode } from "react";
2import { getClassName } from "@bht-component-registry/class-names";
3import { ImageIcon } from "@behivetech/atoms.icon";
4import { useImageStatus } from "@behivetech/hooks.use-image-status";
5import styles from "./image-card-media.module.scss";
6
7/** The media box ratios the design allows, written as CSS ratios. */
8export type ImageCardAspectRatio = "4/5" | "1/1" | "3/2";
9
10export interface ImageCardMediaProps {
11 /** The image, or any node that holds one; see `ImageCardProps.media` */
12 media: ReactNode;
13 /** Label for the failed-image fallback, read before the image's alt text */
14 unavailableLabel: string;
15 /** Aspect ratio of the box, held before the image loads */
16 aspectRatio: ImageCardAspectRatio;
17}
18
19/** Modifier per ratio; a lookup, so a new ratio is one key here and one rule in the stylesheet. */
20const ASPECT_MODIFIERS: Record<ImageCardAspectRatio, string> = {
21 "4/5": "aspect-4-5",
22 "1/1": "aspect-1-1",
23 "3/2": "aspect-3-2",
24};
25
26/**
27 * Names the image inside `media` so a changed image clears an earlier failure.
28 * It reads `src` from the root element only, because the image is replaced by
29 * the fallback once it fails and can no longer be read from the DOM.
30 */
31const getMediaKey = (media: ReactNode): string => {
32 let key = "";
33 if (isValidElement<{ src?: string | { src?: string } }>(media)) {
34 const { src } = media.props;
35 key = (typeof src === "string" ? src : src?.src) ?? "";
36 }
37 return key;
38};
39
40/**
41 * The card's media box: holds its aspect ratio before the image paints and
42 * swaps a failed image for an image icon at the same ratio.
43 */
44export const ImageCardMedia = ({
45 media,
46 unavailableLabel,
47 aspectRatio,
48}: ImageCardMediaProps) => {
49 const { ref, status, alt, handlers } = useImageStatus(getMediaKey(media));
50 const [rootClass, getChildClass] = getClassName({
51 rootClass: "BHT__ImageCardMedia",
52 modifiers: { [ASPECT_MODIFIERS[aspectRatio]]: true },
53 styles,
54 });
55
56 return (
57 <div ref={ref} className={rootClass} {...handlers}>
58 {status === "error" ? (
59 <span
60 className={getChildClass("fallback")}
61 role="img"
62 aria-label={alt ? `${unavailableLabel}: ${alt}` : unavailableLabel}
63 >
64 <ImageIcon size="xl" aria-hidden="true" />
65 </span>
66 ) : (
67 media
68 )}
69 </div>
70 );
71};
image-card.tsx
1import { type ElementType, type HTMLAttributes, type ReactNode } from "react";
2import { getClassName } from "@bht-component-registry/class-names";
3import { Card, type CardHeadingLevel } from "@behivetech/atoms.card";
4import styles from "./image-card.module.scss";
5import {
6 ImageCardMedia,
7 type ImageCardAspectRatio,
8} from "./image-card-media.js";
9
10export type { ImageCardAspectRatio };
11
12export interface ImageCardProps extends Omit<
13 HTMLAttributes<HTMLDivElement>,
14 "title" | "onSelect"
15> {
16 /**
17 * The image, or any node that holds one (`next/image` with `fill` works).
18 * Give the image its descriptive `alt`. If an `<img>` inside fails to load,
19 * including before hydration, the media box swaps it for an image icon at
20 * the same aspect ratio, labelled with `unavailableLabel` and the alt text.
21 * A new `src` on the image clears the failure, but only when `media` is the
22 * image element itself (an `<img>` or `next/image`). If `media` is a wrapper
23 * or a `<picture>`, give the card a `key` that changes with the image.
24 */
25 media: ReactNode;
26 /** Card title, clamped at two lines. It names the link when `href` is set. */
27 title: ReactNode;
28 /** Optional line under the title, clamped at one line */
29 subtitle?: ReactNode;
30 /** Optional row on the card's bottom edge (a price, a status badge); keep it non-interactive */
31 meta?: ReactNode;
32 /**
33 * Makes the whole card one link, named by the title. Omit for a static card
34 * with no hover and no focus stop.
35 */
36 href?: string;
37 /**
38 * Component that renders the link, for client-side routing (pass `next/link`).
39 * Receives `href` and `className`. Defaults to a plain `a`.
40 */
41 linkComponent?: ElementType;
42 /** Label for the failed-image fallback, read before the image's alt text. Defaults to "Image unavailable"; pass your translation. */
43 unavailableLabel?: string;
44 /** Aspect ratio of the media box, held before the image loads. Defaults to `4/5`. */
45 aspectRatio?: ImageCardAspectRatio;
46 /** Which heading tag (`h1`-`h6`) wraps the title. Defaults to `3`. */
47 headingLevel?: CardHeadingLevel;
48 /** Additional class names to merge with the component root element */
49 className?: string;
50}
51
52const DEFAULT_UNAVAILABLE_LABEL = "Image unavailable";
53
54/**
55 * A card with an image on top and a title, subtitle and meta row below. It
56 * composes `atoms.card` with no padding, so fill, border and corners are
57 * Card's. With `href` the whole card is one link named by the title.
58 */
59export const ImageCard = ({
60 media,
61 title,
62 subtitle,
63 meta,
64 href,
65 linkComponent: LinkTag = "a",
66 unavailableLabel = DEFAULT_UNAVAILABLE_LABEL,
67 aspectRatio = "4/5",
68 headingLevel = 3,
69 className,
70 ...rest
71}: ImageCardProps) => {
72 const HeadingTag = `h${headingLevel}` as ElementType;
73 const isLinked = href !== undefined;
74
75 const [rootClass, getChildClass] = getClassName({
76 className,
77 rootClass: "BHT__ImageCard",
78 modifiers: { linked: isLinked },
79 styles,
80 });
81
82 return (
83 <Card {...rest} className={rootClass} padding="none">
84 <div className={getChildClass("content")}>
85 <ImageCardMedia
86 media={media}
87 unavailableLabel={unavailableLabel}
88 aspectRatio={aspectRatio}
89 />
90 <div className={getChildClass("body")}>
91 <HeadingTag className={getChildClass("title")}>
92 {isLinked ? (
93 <LinkTag href={href} className={getChildClass("link")}>
94 {title}
95 </LinkTag>
96 ) : (
97 title
98 )}
99 </HeadingTag>
100 {subtitle !== undefined && (
101 <p className={getChildClass("subtitle")}>{subtitle}</p>
102 )}
103 {meta !== undefined && (
104 <div className={getChildClass("meta")}>{meta}</div>
105 )}
106 </div>
107 </div>
108 </Card>
109 );
110};
image-card-media.module.scss
1// The media box holds its ratio before the image paints, so nothing below shifts.
2.BHT__ImageCardMedia {
3 position: relative;
4 overflow: hidden;
5 flex-shrink: 0;
6 width: 100%;
7 background: var(--md-sys-color-surface-container-highest);
8
9 img,
10 picture {
11 display: block;
12 width: 100%;
13 height: 100%;
14 object-fit: cover;
15 }
16
17 &__fallback {
18 position: absolute;
19 inset: 0;
20 display: flex;
21 align-items: center;
22 justify-content: center;
23 color: var(--md-sys-color-on-surface-variant);
24 }
25
26 &--aspect-4-5 {
27 aspect-ratio: 4 / 5;
28 }
29
30 &--aspect-1-1 {
31 aspect-ratio: 1 / 1;
32 }
33
34 &--aspect-3-2 {
35 aspect-ratio: 3 / 2;
36 }
37}
image-card.module.scss
1// Composes atoms.card (padding none), which owns fill, border and corners.
2// This root class is merged onto Card's root, so these rules style the card
3// itself, not Card's inner classes.
4
5.BHT__ImageCard {
6 position: relative;
7 overflow: hidden;
8 // Fills the grid row so metas line up across a row. Card's body is its only
9 // child; a one-track grid stretches it to the card's height.
10 display: grid;
11 grid-template-rows: minmax(0, 1fr);
12 height: 100%;
13 transition: box-shadow var(--md-sys-motion-duration-short-2, 100ms)
14 var(--md-sys-motion-easing-standard, ease);
15
16 &__content {
17 display: flex;
18 flex-direction: column;
19 height: 100%;
20 }
21
22 &__body {
23 display: flex;
24 flex: 1;
25 flex-direction: column;
26 padding: var(--spacing-4);
27 text-align: start;
28 }
29
30 &__title {
31 margin: 0;
32 font-family: var(--md-sys-typescale-title-medium-font, var(--font-heading));
33 font-size: var(--md-sys-typescale-title-medium-size, 1rem);
34 font-weight: var(--md-sys-typescale-title-medium-weight, 500);
35 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);
36 letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0.0094em);
37 color: var(--md-sys-color-on-surface);
38 overflow-wrap: anywhere;
39 display: -webkit-box;
40 -webkit-box-orient: vertical;
41 -webkit-line-clamp: 2;
42 line-clamp: 2;
43 overflow: hidden;
44 }
45
46 &__subtitle {
47 margin: var(--spacing-1) 0 0;
48 font-family: var(--md-sys-typescale-body-medium-font, var(--font-body));
49 font-size: var(--md-sys-typescale-body-medium-size, 0.875rem);
50 line-height: var(--md-sys-typescale-body-medium-line-height, 1.25rem);
51 letter-spacing: var(--md-sys-typescale-body-medium-tracking, 0.0179em);
52 color: var(--md-sys-color-on-surface-variant);
53 display: -webkit-box;
54 -webkit-box-orient: vertical;
55 -webkit-line-clamp: 1;
56 line-clamp: 1;
57 overflow: hidden;
58 }
59
60 // Bottom edge of the card; wraps when the row is narrow.
61 &__meta {
62 display: flex;
63 flex-wrap: wrap;
64 align-items: center;
65 justify-content: space-between;
66 gap: var(--spacing-2);
67 margin-top: auto;
68 padding-top: var(--spacing-3);
69 }
70
71 &__link {
72 color: inherit;
73 text-decoration: none;
74 // The ring is drawn on ::before below, not on the link's own box.
75 outline: none;
76
77 // The link's hit area is the whole card, so one link carries the one
78 // name. ::after is also the state layer, as on Card.
79 &::after {
80 content: "";
81 position: absolute;
82 inset: 0;
83 background: var(--md-sys-color-on-surface);
84 opacity: 0;
85 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
86 var(--md-sys-motion-easing-standard, ease);
87 }
88
89 // The focus ring is its own full-card layer: opacity on ::after would
90 // fade the ring with the state layer. Inset, like Card's interactive
91 // body, because the root clips an outside ring.
92 &::before {
93 content: "";
94 position: absolute;
95 inset: 0;
96 z-index: 1;
97 pointer-events: none;
98 }
99
100 &:hover::after {
101 opacity: var(--md-sys-state-hover-opacity, 0.08);
102 }
103
104 &:focus-visible::after {
105 opacity: var(--md-sys-state-focus-opacity, 0.1);
106 }
107
108 &:focus-visible::before {
109 outline: 3px solid
110 var(--md-sys-color-secondary, var(--color-border-focus));
111 outline-offset: -3px;
112 }
113
114 &:active::after {
115 opacity: var(--md-sys-state-pressed-opacity, 0.1);
116 }
117 }
118
119 // The glow is on the card (an object), never on text; transparent in Light.
120 &--linked:hover {
121 box-shadow: 0 0 24px var(--brand-glow-violet, transparent);
122 }
123}
index.ts
1export { ImageCard } from "./image-card.js";
2export type { ImageCardProps, ImageCardAspectRatio } from "./image-card.js";
image-card.example.tsx
1import { ImageCard } from "./image-card.js";
2
3const PLACEHOLDER_IMAGE =
4 "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='400' height='500'><rect width='400' height='500' fill='%23bc13fe'/></svg>";
5const BROKEN_IMAGE = "https://invalid.example/missing.jpg";
6
7/** A linked artwork card: the whole card is one link named by the title. */
8export const LinkedImageCard = () => (
9 <ImageCard
10 href="/art/blue-hour"
11 media={
12 <img
13 src={PLACEHOLDER_IMAGE}
14 alt="Blue hour on the porch, oil on canvas"
15 />
16 }
17 title="Blue hour on the porch"
18 subtitle="Lena Park"
19 meta="$480.00"
20 />
21);
22
23/** A static card with a square media box, as for an artist tile. */
24export const SquareImageCard = () => (
25 <ImageCard
26 aspectRatio="1/1"
27 media={<img src={PLACEHOLDER_IMAGE} alt="Lena Park in her studio" />}
28 title="Lena Park"
29 />
30);
31
32/** A 3/2 media box, as for a booking preview. */
33export const WideImageCard = () => (
34 <ImageCard
35 aspectRatio="3/2"
36 href="/bookings/studio-visit"
37 media={<img src={PLACEHOLDER_IMAGE} alt="The studio, morning light" />}
38 title="Studio visit"
39 subtitle="Saturday, 10:00"
40 />
41);
42
43/** An image that fails to load shows the image icon at the same ratio. */
44export const FailedImageCard = () => (
45 <ImageCard
46 media={
47 <img src={BROKEN_IMAGE} alt="Hive study no. 3, the garden at dusk" />
48 }
49 title="Hive study no. 3, the garden at dusk"
50 subtitle="Theo Grant"
51 meta="$1,250.00"
52 />
53);
54
55/** Cards in a responsive grid row: two across from a 352px container, more as it widens, with a long title and a missing subtitle. */
56export const ImageCardGridRow = () => (
57 <div
58 style={{
59 display: "grid",
60 gridTemplateColumns: "repeat(auto-fill, minmax(10.5rem, 1fr))",
61 gap: "1rem",
62 }}
63 >
64 <ImageCard
65 href="/art/blue-hour"
66 media={<img src={PLACEHOLDER_IMAGE} alt="Blue hour on the porch" />}
67 title="Blue hour on the porch"
68 subtitle="Lena Park"
69 meta="$480.00"
70 />
71 <ImageCard
72 href="/art/hive-study-3"
73 media={<img src={PLACEHOLDER_IMAGE} alt="Hive study no. 3" />}
74 title="Hive study no. 3, the garden at dusk, with a title long enough to clamp"
75 subtitle="Theo Grant"
76 meta="$1,250.00"
77 />
78 <ImageCard
79 href="/art/morning-wall"
80 media={<img src={PLACEHOLDER_IMAGE} alt="Morning wall" />}
81 title="Morning wall"
82 />
83 </div>
84);