Foundations
FoundationsTier 1 · Tokens
Imagery
WoohlImage with the house aspect ratios, placeholder treatment and scrim helpers for text over photography.
bunx --bun shadcn@latest add @woohl/imageryReference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/imageryregistry/tier-1-tokens/imagery/woohl-image.tsx
"use client";
import {
useState,
type ElementType,
type ImgHTMLAttributes,
type ReactNode,
} from "react";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import {
IMAGE_RATIOS,
type ImageRatio,
} from "@/registry/tier-1-tokens/lib/constants";
import {
scrimGradient,
type ScrimDirection,
type ScrimStrength,
} from "./scrim";
/** Image wrapper with aspect ratios, overlays, scrims, and hatch fallback. */
type WoohlImageOwnProps = {
src: string;
/** Descriptive alt text. */
alt: string;
/** Renders empty alt="" for decorative images. */
decorative?: boolean;
/** Aspect ratio preset (4:5, 1:1, 16:9, 9:16, bleed). */
ratio?: ImageRatio;
/** Border radius preset. */
radius?: "none" | "image" | "artboard" | "pill";
/** Content laid over the image. Implies a scrim unless you turn it off. */
overlay?: ReactNode;
scrim?: boolean | ScrimStrength;
scrimDirection?: ScrimDirection;
/** Where the overlay content sits. */
align?: "bottom" | "top" | "center";
/** `cover` fills the crop. `contain` is for catalogue cut-outs on white. */
fit?: "cover" | "contain";
/** Element rendered for the image itself. Pass next/image here. */
as?: ElementType;
className?: string;
imageClassName?: string;
};
export type WoohlImageProps = WoohlImageOwnProps &
Omit<ImgHTMLAttributes<HTMLImageElement>, keyof WoohlImageOwnProps | "ref">;
const RADIUS_CLASS = {
none: "rounded-none",
image: "rounded-lg", // 16px — the product value
artboard: "rounded-[var(--radius-image-artboard)]", // 32px — the 1080 artboard value
pill: "rounded-pill", // avatars only
} as const;
const ALIGN_CLASS = {
bottom: "items-end",
top: "items-start",
center: "items-center",
} as const;
export function WoohlImage({
src,
alt,
decorative = false,
ratio = "portrait",
radius = "image",
overlay,
scrim,
scrimDirection = "bottom",
align = "bottom",
fit = "cover",
as: ImageComponent = "img",
className,
imageClassName,
...props
}: WoohlImageProps) {
const [failed, setFailed] = useState(false);
// Content over an image needs a scrim. Passing overlay without asking for one
// is a forgotten scrim, not a design choice — so it defaults on.
const scrimStrength: ScrimStrength | null =
scrim === false
? null
: scrim === true || scrim === undefined
? overlay
? "default"
: null
: scrim;
const aspect = ratio === "bleed" ? undefined : IMAGE_RATIOS[ratio];
return (
<div
className={cn(
"bg-surface-sunken relative isolate overflow-hidden",
ratio === "bleed" && "size-full",
RADIUS_CLASS[radius],
className,
)}
style={aspect ? { aspectRatio: aspect } : undefined}
>
{failed || !src ? (
<ImageFallback alt={decorative ? "" : alt} />
) : (
<ImageComponent
src={src}
alt={decorative ? "" : alt}
onError={() => setFailed(true)}
className={cn(
"size-full",
fit === "cover" ? "object-cover" : "object-contain",
imageClassName,
)}
{...props}
/>
)}
{scrimStrength && (
<div
aria-hidden
className="z-raised absolute inset-0"
style={{ background: scrimGradient(scrimDirection, scrimStrength) }}
/>
)}
{overlay && (
<div
className={cn(
"z-raised absolute inset-0 flex p-4",
ALIGN_CLASS[align],
)}
>
{overlay}
</div>
)}
</div>
);
}
/** Stands in for a photograph not yet shot, or one that failed to load. Looks
* unfinished on purpose — if it reaches production, the work isn't done. */
export function ImageFallback({
alt,
className,
}: {
alt?: string;
className?: string;
}) {
return (
<div
role={alt ? "img" : "presentation"}
aria-label={alt || undefined}
aria-hidden={alt ? undefined : true}
className={cn("size-full", className)}
style={{ background: "var(--hatch)" }}
/>
);
}
/** Caption pill for a hero image. */
export function ImageCaption({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<span
className={cn(
"rounded-pill text-caption text-text-on-fill inline-flex items-center gap-2 px-3 py-1.5 font-medium",
"bg-surface-media backdrop-blur-[2px]",
className,
)}
>
{children}
</span>
);
}
/** Decorative bird watermark. */
export function ImageWatermark({ children }: { children: ReactNode }) {
return (
<span
aria-hidden
className="pointer-events-none absolute -right-6 -bottom-8 opacity-[0.08]"
>
{children}
</span>
);
}
WoohlImageProps
Also accepts everything from WoohlImageOwnProps & Omit<ImgHTMLAttributes<HTMLImageElement>, keyof WoohlImageOwnProps | "ref">.
Registry items
npm packages
None beyond React itself.