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.
registry/behivetech/molecules/image-card at v0.2.1, snapshotted 2026-11-10
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";67/** The media box ratios the design allows, written as CSS ratios. */8export type ImageCardAspectRatio = "4/5" | "1/1" | "3/2";910export 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}1819/** 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};2526/**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 by29 * 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};3940/**41 * The card's media box: holds its aspect ratio before the image paints and42 * 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 });5556 return (57 <div ref={ref} className={rootClass} {...handlers}>58 {status === "error" ? (59 <span60 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 media68 )}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";910export type { ImageCardAspectRatio };1112export 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 at20 * 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 the22 * image element itself (an `<img>` or `next/image`). If `media` is a wrapper23 * 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 card34 * 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}5152const DEFAULT_UNAVAILABLE_LABEL = "Image unavailable";5354/**55 * A card with an image on top and a title, subtitle and meta row below. It56 * composes `atoms.card` with no padding, so fill, border and corners are57 * 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 ...rest71}: ImageCardProps) => {72 const HeadingTag = `h${headingLevel}` as ElementType;73 const isLinked = href !== undefined;7475 const [rootClass, getChildClass] = getClassName({76 className,77 rootClass: "BHT__ImageCard",78 modifiers: { linked: isLinked },79 styles,80 });8182 return (83 <Card {...rest} className={rootClass} padding="none">84 <div className={getChildClass("content")}>85 <ImageCardMedia86 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 title98 )}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);89 img,10 picture {11 display: block;12 width: 100%;13 height: 100%;14 object-fit: cover;15 }1617 &__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 }2526 &--aspect-4-5 {27 aspect-ratio: 4 / 5;28 }2930 &--aspect-1-1 {31 aspect-ratio: 1 / 1;32 }3334 &--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 card3// itself, not Card's inner classes.45.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 only9 // 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);1516 &__content {17 display: flex;18 flex-direction: column;19 height: 100%;20 }2122 &__body {23 display: flex;24 flex: 1;25 flex-direction: column;26 padding: var(--spacing-4);27 text-align: start;28 }2930 &__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 }4546 &__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 }5960 // 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 }7071 &__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;7677 // The link's hit area is the whole card, so one link carries the one78 // 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 }8889 // The focus ring is its own full-card layer: opacity on ::after would90 // fade the ring with the state layer. Inset, like Card's interactive91 // 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 }99100 &:hover::after {101 opacity: var(--md-sys-state-hover-opacity, 0.08);102 }103104 &:focus-visible::after {105 opacity: var(--md-sys-state-focus-opacity, 0.1);106 }107108 &:focus-visible::before {109 outline: 3px solid110 var(--md-sys-color-secondary, var(--color-border-focus));111 outline-offset: -3px;112 }113114 &:active::after {115 opacity: var(--md-sys-state-pressed-opacity, 0.1);116 }117 }118119 // 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";23const 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";67/** A linked artwork card: the whole card is one link named by the title. */8export const LinkedImageCard = () => (9 <ImageCard10 href="/art/blue-hour"11 media={12 <img13 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);2223/** A static card with a square media box, as for an artist tile. */24export const SquareImageCard = () => (25 <ImageCard26 aspectRatio="1/1"27 media={<img src={PLACEHOLDER_IMAGE} alt="Lena Park in her studio" />}28 title="Lena Park"29 />30);3132/** A 3/2 media box, as for a booking preview. */33export const WideImageCard = () => (34 <ImageCard35 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);4243/** An image that fails to load shows the image icon at the same ratio. */44export const FailedImageCard = () => (45 <ImageCard46 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);5455/** 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 <div58 style={{59 display: "grid",60 gridTemplateColumns: "repeat(auto-fill, minmax(10.5rem, 1fr))",61 gap: "1rem",62 }}63 >64 <ImageCard65 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 <ImageCard72 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 <ImageCard79 href="/art/morning-wall"80 media={<img src={PLACEHOLDER_IMAGE} alt="Morning wall" />}81 title="Morning wall"82 />83 </div>84);