Component
Horizontal Card
A responsive horizontal card: media in a fixed left column with content beside it from `sm` up, collapsing to a top banner + stacked content below `sm`. Uses `CardMedia aspect="none"` with showcase-level layout classes.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/cards/card-horizontal/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Cards/card-horizontal React/Components/Cards/card-horizontal import { useId, useState } from "react";
import type {
AnchorHTMLAttributes,
ButtonHTMLAttributes,
ChangeEvent,
HTMLAttributes,
ImgHTMLAttributes,
ReactNode,
} from "react";
/**
* DevSnips React Card — Horizontal media layout.
*
* The shared card core; this variant demonstrates the horizontal pattern:
* the root gets `overflow-hidden sm:flex-row`, the media frame gets
* `aspect="none"` + `h-40 sm:h-auto sm:w-48`, and the header/footer
* regions stack in a flex column beside it — collapsing to a top banner
* below `sm` without clipping content.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
const CARD_CLASSES =
"relative flex w-full min-w-0 flex-col rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)]";
// Grid with a text column and an auto-sized action column: the title and
// description stack in column 1 and an optional CardAction sits at the top
// of column 2. With no action the second column collapses to zero width.
const HEADER_CLASSES =
"grid grid-cols-[1fr_auto] items-start gap-x-4 gap-y-1.5 px-5 pt-5";
const TITLE_CLASSES =
"col-start-1 text-lg font-semibold leading-[1.35] tracking-[-0.01em] text-[var(--ds-color-foreground)]";
const DESCRIPTION_CLASSES =
"col-start-1 text-sm leading-5 text-[var(--ds-color-muted-foreground)]";
const ACTION_CLASSES = "col-start-2 row-start-1 flex shrink-0 items-center gap-1";
const CONTENT_CLASSES = "min-w-0 px-5 py-4";
// Mirrors the dialog footer: actions stack full-width below `sm` (primary
// last in DOM so it lands on top), lay out inline from `sm` up. Alignment is
// intentionally unset — pass `sm:justify-end` or `sm:justify-between` via
// className (no baked-in justify utility, so overrides never conflict).
const FOOTER_CLASSES =
"mt-auto flex flex-col-reverse gap-2 px-5 pb-5 pt-4 sm:flex-row sm:items-center [&>button]:w-full sm:[&>button]:w-auto";
const MEDIA_ASPECT_CLASSES: Record<CardMediaAspect, string> = {
video: "aspect-video",
square: "aspect-square",
none: "",
};
const INTERACTIVE_CARD_CLASSES =
"flex w-full min-w-0 flex-col rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-left text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] hover:bg-[var(--ds-color-surface-hover)] active:bg-[var(--ds-color-surface-active)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const SELECTABLE_LABEL_BASE_CLASSES =
"relative flex w-full min-w-0 cursor-pointer flex-col gap-1.5 rounded-[var(--ds-radius-md)] border bg-[var(--ds-color-surface)] p-4 text-left shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-within:border-[var(--ds-color-border-strong)] motion-reduce:transition-none";
const SELECTABLE_INPUT_CLASSES =
"size-[18px] cursor-pointer appearance-none border bg-[var(--ds-color-input)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none";
const SKELETON_BLOCK_CLASSES =
"animate-pulse rounded-[var(--ds-radius-xs)] bg-[var(--ds-color-surface-active)] motion-reduce:animate-none";
/* ------------------------------------------------------------------------ */
/* Card (root surface) */
/* ------------------------------------------------------------------------ */
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
className?: string;
children?: ReactNode;
}
export function Card({ className, children, ...rest }: CardProps) {
return (
<div className={cx(CARD_CLASSES, className)} {...rest}>
{children}
</div>
);
}
/* ------------------------------------------------------------------------ */
/* CardHeader / CardTitle / CardDescription / CardAction */
/* ------------------------------------------------------------------------ */
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
className?: string;
children?: ReactNode;
}
export function CardHeader({ className, children, ...rest }: CardHeaderProps) {
return (
<div className={cx(HEADER_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
className?: string;
children?: ReactNode;
}
/** Real `<h3>` heading — cards are page regions, so titles are headings. */
export function CardTitle({ className, children, ...rest }: CardTitleProps) {
return (
<h3 className={cx(TITLE_CLASSES, className)} {...rest}>
{children}
</h3>
);
}
export interface CardDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
className?: string;
children?: ReactNode;
}
export function CardDescription({ className, children, ...rest }: CardDescriptionProps) {
return (
<p className={cx(DESCRIPTION_CLASSES, className)} {...rest}>
{children}
</p>
);
}
export interface CardActionProps extends HTMLAttributes<HTMLDivElement> {
className?: string;
children?: ReactNode;
}
/** Header action slot (icon buttons, a menu trigger) — top-right of the header. */
export function CardAction({ className, children, ...rest }: CardActionProps) {
return (
<div className={cx(ACTION_CLASSES, className)} {...rest}>
{children}
</div>
);
}
/* ------------------------------------------------------------------------ */
/* CardContent / CardFooter */
/* ------------------------------------------------------------------------ */
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
className?: string;
children?: ReactNode;
}
export function CardContent({ className, children, ...rest }: CardContentProps) {
return (
<div className={cx(CONTENT_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
className?: string;
children?: ReactNode;
}
export function CardFooter({ className, children, ...rest }: CardFooterProps) {
return (
<div className={cx(FOOTER_CLASSES, className)} {...rest}>
{children}
</div>
);
}
/* ------------------------------------------------------------------------ */
/* CardMedia */
/* ------------------------------------------------------------------------ */
export type CardMediaAspect = "video" | "square" | "none";
export interface CardMediaProps extends ImgHTMLAttributes<HTMLImageElement> {
/**
* Image URL. When omitted, a decorative `aria-hidden` placeholder surface
* renders instead (the layout never collapses on a missing image).
*/
src?: string;
/** Alternative text. Defaults to `""` (decorative); meaningful images must pass real alt text. */
alt?: string;
/** Crop box: `video` 16:9 (default), `square` 1:1, `none` natural height (for fixed-size layouts such as horizontal cards). */
aspect?: CardMediaAspect;
/** Extra classes on the media frame (the image always fills it with `object-cover`). */
className?: string;
}
const MEDIA_FALLBACK_ICON = (
<svg
className="size-6"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
export function CardMedia({ src, alt = "", aspect = "video", className, ...rest }: CardMediaProps) {
return (
<div
className={cx(
"shrink-0 overflow-hidden rounded-t-[calc(var(--ds-radius-md)-1px)] bg-[var(--ds-color-surface-subtle)]",
MEDIA_ASPECT_CLASSES[aspect],
className,
)}
>
{src ? (
<img src={src} alt={alt} loading="lazy" className="block h-full w-full object-cover" {...rest} />
) : (
<div
aria-hidden="true"
className="flex h-full min-h-[6rem] w-full items-center justify-center text-[var(--ds-color-muted-foreground)]"
>
{MEDIA_FALLBACK_ICON}
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------------ */
/* SelectableCard (real radio / checkbox, card as label) */
/* ------------------------------------------------------------------------ */
export interface SelectableCardProps {
/** `checkbox` (default) for independent multi-select; `radio` for a single choice within a `name` group — use `SelectableCardGroup` to manage the group. */
type?: "radio" | "checkbox";
/** Visible card label (also the input's accessible name). */
label: ReactNode;
/** Supporting text; wired to the input via `aria-describedby`. */
description?: ReactNode;
checked?: boolean;
defaultChecked?: boolean;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
required?: boolean;
name?: string;
value?: string | number | readonly string[];
id?: string;
"aria-describedby"?: string;
className?: string;
}
/**
* A selectable card: the whole card is the `<label>` of a real native
* `<input type="radio">` / `type="checkbox">`, so clicking anywhere on the
* card toggles the input and all native behavior (Space toggling, arrow-key
* radio navigation, form submission) works. The selected state is tracked
* from React state (controlled `checked` + `onChange`, or uncontrolled
* `defaultChecked`) so it stays correct in both modes; it is shown with a
* primary border plus the visible control — never color alone.
*/
export function SelectableCard({
type = "checkbox",
label,
description,
checked,
defaultChecked,
onChange,
disabled,
required,
name,
value,
id,
"aria-describedby": ariaDescribedby,
className,
}: SelectableCardProps) {
const generatedId = useId();
const inputId = id ?? `selectable-card-${generatedId}`;
const descId = `${inputId}-desc`;
const isControlled = checked !== undefined;
const [internal, setInternal] = useState<boolean>(defaultChecked ?? false);
const isChecked = isControlled ? checked : internal;
const describedby = [description ? descId : null, ariaDescribedby].filter(Boolean).join(" ") || undefined;
function handleChange(event: ChangeEvent<HTMLInputElement>) {
if (!isControlled) setInternal(event.target.checked);
onChange?.(event);
}
return (
<label
htmlFor={inputId}
className={cx(
SELECTABLE_LABEL_BASE_CLASSES,
disabled && "cursor-not-allowed opacity-60 hover:bg-[var(--ds-color-surface)]",
isChecked ? "border-[var(--ds-color-primary)]" : "border-[var(--ds-color-border)]",
className,
)}
>
<span className="flex items-start justify-between gap-3">
<span className="min-w-0 text-sm font-medium leading-5 text-[var(--ds-color-foreground)]">
{label}
{required ? (
<span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span>
) : null}
</span>
<span className="relative mt-0.5 inline-flex size-[18px] shrink-0 items-center justify-center">
<input
id={inputId}
type={type}
className={cx(
SELECTABLE_INPUT_CLASSES,
type === "radio"
? "rounded-full border-[var(--ds-color-border)] checked:border-[var(--ds-color-primary)]"
: "rounded-[var(--ds-radius-xs)] border-[var(--ds-color-border)] checked:border-[var(--ds-color-primary)] checked:bg-[var(--ds-color-primary)]",
)}
checked={isControlled ? isChecked : undefined}
defaultChecked={isControlled ? undefined : defaultChecked}
disabled={disabled}
required={required}
aria-describedby={describedby}
name={name}
value={value}
onChange={handleChange}
/>
<span
aria-hidden="true"
className={cx(
"pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-150 motion-reduce:transition-none",
type === "checkbox" && "text-[var(--ds-color-primary-foreground)]",
isChecked ? "opacity-100" : "opacity-0",
)}
>
{type === "radio" ? (
<span className="block size-[8px] rounded-full bg-[var(--ds-color-primary)]" />
) : (
<svg className="size-[12px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
)}
</span>
</span>
</span>
{description ? (
<span id={descId} className="text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
{description}
</span>
) : null}
</label>
);
}
/* ------------------------------------------------------------------------ */
/* SelectableCardGroup (single-choice fieldset of selectable cards) */
/* ------------------------------------------------------------------------ */
export interface SelectableCardOption {
value: string;
label: ReactNode;
description?: ReactNode;
disabled?: boolean;
}
export interface SelectableCardGroupProps {
/** Visible `<legend>` for the fieldset. */
legend: ReactNode;
options: SelectableCardOption[];
/** Selected option value (controlled). */
value?: string;
/** Initially selected option value (uncontrolled). */
defaultValue?: string;
onChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
required?: boolean;
name?: string;
id?: string;
/** Card columns from `sm` up (1, 2, or 3). */
columns?: 1 | 2 | 3;
className?: string;
}
/**
* A single-choice group of selectable cards inside a `<fieldset>`/`<legend>`.
* The group owns the selected value (controlled `value` + `onChange`, or
* uncontrolled `defaultValue`) and passes it down as controlled `checked`, so
* every card's selected state stays in sync even in uncontrolled mode —
* where a deselected radio receives no change event of its own. Keyboard
* users get the browser's native radio-group arrow-key navigation.
*/
export function SelectableCardGroup({
legend,
options,
value,
defaultValue = "",
onChange,
disabled,
required,
name,
id,
columns = 1,
className,
}: SelectableCardGroupProps) {
const generatedId = useId();
const groupId = id ?? `selectable-card-group-${generatedId}`;
const groupName = name ?? groupId;
const isControlled = value !== undefined;
const [internal, setInternal] = useState<string>(defaultValue);
const selected = isControlled ? value : internal;
function handleChange(option: SelectableCardOption, event: ChangeEvent<HTMLInputElement>) {
if (!isControlled) setInternal(option.value);
onChange?.(option.value, event);
}
const gridCols = columns === 3 ? "sm:grid-cols-3" : columns === 2 ? "sm:grid-cols-2" : "grid-cols-1";
return (
<fieldset id={groupId} className={cx("min-w-0 border-0 p-0", className)}>
<legend className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]">
{legend}
{required ? (
<span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span>
) : null}
</legend>
<div className={cx("grid gap-3", gridCols)}>
{options.map((option) => (
<SelectableCard
key={option.value}
type="radio"
label={option.label}
description={option.description}
name={groupName}
value={option.value}
checked={selected === option.value}
disabled={option.disabled || disabled}
required={required}
onChange={(event) => handleChange(option, event)}
/>
))}
</div>
</fieldset>
);
}
/* ------------------------------------------------------------------------ */
/* InteractiveCard (real anchor for navigation, real button for actions) */
/* ------------------------------------------------------------------------ */
interface InteractiveCardSharedProps {
className?: string;
children?: ReactNode;
}
export interface InteractiveCardAnchorProps
extends InteractiveCardSharedProps,
AnchorHTMLAttributes<HTMLAnchorElement> {
/** Destination URL — renders a real `<a>` with normal browser navigation. */
href: string;
}
export interface InteractiveCardButtonProps
extends InteractiveCardSharedProps,
ButtonHTMLAttributes<HTMLButtonElement> {
href?: undefined;
}
export type InteractiveCardProps = InteractiveCardAnchorProps | InteractiveCardButtonProps;
/**
* A card that is itself the interactive element — never a `<div>` with a
* click handler. With `href` it renders a real anchor (navigation: middle-
* click, open-in-new-tab, and screen-reader link semantics all work);
* without `href` it renders a real `<button type="button">` (actions), which
* also carries the only meaningful `disabled` state. Because the whole card
* is one control, keep other interactive elements (links, buttons, menus)
* out of its children — nested interactive elements are invalid and confuse
* activation. Put secondary actions in a sibling card's `CardAction` instead.
*/
export function InteractiveCard(props: InteractiveCardProps) {
if (props.href !== undefined) {
const { className, children, ...rest } = props;
return (
<a className={cx(INTERACTIVE_CARD_CLASSES, className)} {...rest}>
{children}
</a>
);
}
const { className, children, type, ...rest } = props;
return (
<button type={type ?? "button"} className={cx(INTERACTIVE_CARD_CLASSES, className)} {...rest}>
{children}
</button>
);
}
/* ------------------------------------------------------------------------ */
/* CardSkeleton (loading placeholder) */
/* ------------------------------------------------------------------------ */
export interface CardSkeletonProps {
/** Render a media placeholder block (16:9) at the top of the card. */
media?: boolean;
/** Number of body text lines (default 2). */
lines?: number;
/** Render an action-row placeholder in the footer position. */
footer?: boolean;
/** Visually hidden loading announcement (default "Loading…"). */
label?: string;
className?: string;
}
/**
* Loading placeholder matching the real card's geometry, so content swaps in
* without layout shift. The card carries `aria-busy="true"` plus a visually
* hidden loading label; the placeholder blocks are `aria-hidden`. The pulse
* is a restrained opacity animation and is disabled under
* `prefers-reduced-motion`.
*/
export function CardSkeleton({ media = false, lines = 2, footer = false, label = "Loading…", className }: CardSkeletonProps) {
const lineCount = Math.max(1, Math.round(lines));
return (
<Card aria-busy="true" className={className}>
<span className="sr-only">{label}</span>
<div aria-hidden="true">
{media ? <div className={cx(SKELETON_BLOCK_CLASSES, "mx-5 mt-5 aspect-video")} /> : null}
<div className={cx("flex flex-col gap-2 px-5", media ? "pt-4" : "pt-5", footer ? "pb-4" : "pb-5")}>
<div className={cx(SKELETON_BLOCK_CLASSES, "h-4 w-2/5")} />
{Array.from({ length: lineCount }, (_, i) => (
<div
key={i}
className={cx(SKELETON_BLOCK_CLASSES, "h-3", i === lineCount - 1 ? "w-3/5" : "w-full")}
/>
))}
</div>
{footer ? (
<div className="flex gap-2 px-5 pb-5 pt-4">
<div className={cx(SKELETON_BLOCK_CLASSES, "h-9 w-24")} />
<div className={cx(SKELETON_BLOCK_CLASSES, "h-9 w-24")} />
</div>
) : null}
</div>
</Card>
);
}
export default Card; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import { useId, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const CARD_CLASSES = "relative flex w-full min-w-0 flex-col rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)]";
const HEADER_CLASSES = "grid grid-cols-[1fr_auto] items-start gap-x-4 gap-y-1.5 px-5 pt-5";
const TITLE_CLASSES = "col-start-1 text-lg font-semibold leading-[1.35] tracking-[-0.01em] text-[var(--ds-color-foreground)]";
const DESCRIPTION_CLASSES = "col-start-1 text-sm leading-5 text-[var(--ds-color-muted-foreground)]";
const ACTION_CLASSES = "col-start-2 row-start-1 flex shrink-0 items-center gap-1";
const CONTENT_CLASSES = "min-w-0 px-5 py-4";
const FOOTER_CLASSES = "mt-auto flex flex-col-reverse gap-2 px-5 pb-5 pt-4 sm:flex-row sm:items-center [&>button]:w-full sm:[&>button]:w-auto";
const MEDIA_ASPECT_CLASSES = {
video: "aspect-video",
square: "aspect-square",
none: ""
};
const INTERACTIVE_CARD_CLASSES = "flex w-full min-w-0 flex-col rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-left text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] hover:bg-[var(--ds-color-surface-hover)] active:bg-[var(--ds-color-surface-active)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const SELECTABLE_LABEL_BASE_CLASSES = "relative flex w-full min-w-0 cursor-pointer flex-col gap-1.5 rounded-[var(--ds-radius-md)] border bg-[var(--ds-color-surface)] p-4 text-left shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-within:border-[var(--ds-color-border-strong)] motion-reduce:transition-none";
const SELECTABLE_INPUT_CLASSES = "size-[18px] cursor-pointer appearance-none border bg-[var(--ds-color-input)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none";
const SKELETON_BLOCK_CLASSES = "animate-pulse rounded-[var(--ds-radius-xs)] bg-[var(--ds-color-surface-active)] motion-reduce:animate-none";
function Card({ className, children, ...rest }) {
return <div className={cx(CARD_CLASSES, className)} {...rest}>
{children}
</div>;
}
function CardHeader({ className, children, ...rest }) {
return <div className={cx(HEADER_CLASSES, className)} {...rest}>
{children}
</div>;
}
function CardTitle({ className, children, ...rest }) {
return <h3 className={cx(TITLE_CLASSES, className)} {...rest}>
{children}
</h3>;
}
function CardDescription({ className, children, ...rest }) {
return <p className={cx(DESCRIPTION_CLASSES, className)} {...rest}>
{children}
</p>;
}
function CardAction({ className, children, ...rest }) {
return <div className={cx(ACTION_CLASSES, className)} {...rest}>
{children}
</div>;
}
function CardContent({ className, children, ...rest }) {
return <div className={cx(CONTENT_CLASSES, className)} {...rest}>
{children}
</div>;
}
function CardFooter({ className, children, ...rest }) {
return <div className={cx(FOOTER_CLASSES, className)} {...rest}>
{children}
</div>;
}
const MEDIA_FALLBACK_ICON = <svg
className="size-6"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>;
function CardMedia({ src, alt = "", aspect = "video", className, ...rest }) {
return <div
className={cx(
"shrink-0 overflow-hidden rounded-t-[calc(var(--ds-radius-md)-1px)] bg-[var(--ds-color-surface-subtle)]",
MEDIA_ASPECT_CLASSES[aspect],
className
)}
>
{src ? <img src={src} alt={alt} loading="lazy" className="block h-full w-full object-cover" {...rest} /> : <div
aria-hidden="true"
className="flex h-full min-h-[6rem] w-full items-center justify-center text-[var(--ds-color-muted-foreground)]"
>
{MEDIA_FALLBACK_ICON}
</div>}
</div>;
}
function SelectableCard({
type = "checkbox",
label,
description,
checked,
defaultChecked,
onChange,
disabled,
required,
name,
value,
id,
"aria-describedby": ariaDescribedby,
className
}) {
const generatedId = useId();
const inputId = id ?? `selectable-card-${generatedId}`;
const descId = `${inputId}-desc`;
const isControlled = checked !== undefined;
const [internal, setInternal] = useState(defaultChecked ?? false);
const isChecked = isControlled ? checked : internal;
const describedby = [description ? descId : null, ariaDescribedby].filter(Boolean).join(" ") || undefined;
function handleChange(event) {
if (!isControlled) setInternal(event.target.checked);
onChange?.(event);
}
return <label
htmlFor={inputId}
className={cx(
SELECTABLE_LABEL_BASE_CLASSES,
disabled && "cursor-not-allowed opacity-60 hover:bg-[var(--ds-color-surface)]",
isChecked ? "border-[var(--ds-color-primary)]" : "border-[var(--ds-color-border)]",
className
)}
>
<span className="flex items-start justify-between gap-3">
<span className="min-w-0 text-sm font-medium leading-5 text-[var(--ds-color-foreground)]">
{label}
{required ? <span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span> : null}
</span>
<span className="relative mt-0.5 inline-flex size-[18px] shrink-0 items-center justify-center">
<input
id={inputId}
type={type}
className={cx(
SELECTABLE_INPUT_CLASSES,
type === "radio" ? "rounded-full border-[var(--ds-color-border)] checked:border-[var(--ds-color-primary)]" : "rounded-[var(--ds-radius-xs)] border-[var(--ds-color-border)] checked:border-[var(--ds-color-primary)] checked:bg-[var(--ds-color-primary)]"
)}
checked={isControlled ? isChecked : undefined}
defaultChecked={isControlled ? undefined : defaultChecked}
disabled={disabled}
required={required}
aria-describedby={describedby}
name={name}
value={value}
onChange={handleChange}
/>
<span
aria-hidden="true"
className={cx(
"pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-150 motion-reduce:transition-none",
type === "checkbox" && "text-[var(--ds-color-primary-foreground)]",
isChecked ? "opacity-100" : "opacity-0"
)}
>
{type === "radio" ? <span className="block size-[8px] rounded-full bg-[var(--ds-color-primary)]" /> : <svg className="size-[12px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>}
</span>
</span>
</span>
{description ? <span id={descId} className="text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
{description}
</span> : null}
</label>;
}
function SelectableCardGroup({
legend,
options,
value,
defaultValue = "",
onChange,
disabled,
required,
name,
id,
columns = 1,
className
}) {
const generatedId = useId();
const groupId = id ?? `selectable-card-group-${generatedId}`;
const groupName = name ?? groupId;
const isControlled = value !== undefined;
const [internal, setInternal] = useState(defaultValue);
const selected = isControlled ? value : internal;
function handleChange(option, event) {
if (!isControlled) setInternal(option.value);
onChange?.(option.value, event);
}
const gridCols = columns === 3 ? "sm:grid-cols-3" : columns === 2 ? "sm:grid-cols-2" : "grid-cols-1";
return <fieldset id={groupId} className={cx("min-w-0 border-0 p-0", className)}>
<legend className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]">
{legend}
{required ? <span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span> : null}
</legend>
<div className={cx("grid gap-3", gridCols)}>
{options.map((option) => <SelectableCard
key={option.value}
type="radio"
label={option.label}
description={option.description}
name={groupName}
value={option.value}
checked={selected === option.value}
disabled={option.disabled || disabled}
required={required}
onChange={(event) => handleChange(option, event)}
/>)}
</div>
</fieldset>;
}
function InteractiveCard(props) {
if (props.href !== undefined) {
const { className: className2, children: children2, ...rest2 } = props;
return <a className={cx(INTERACTIVE_CARD_CLASSES, className2)} {...rest2}>
{children2}
</a>;
}
const { className, children, type, ...rest } = props;
return <button type={type ?? "button"} className={cx(INTERACTIVE_CARD_CLASSES, className)} {...rest}>
{children}
</button>;
}
function CardSkeleton({ media = false, lines = 2, footer = false, label = "Loading\u2026", className }) {
const lineCount = Math.max(1, Math.round(lines));
return <Card aria-busy="true" className={className}>
<span className="sr-only">{label}</span>
<div aria-hidden="true">
{media ? <div className={cx(SKELETON_BLOCK_CLASSES, "mx-5 mt-5 aspect-video")} /> : null}
<div className={cx("flex flex-col gap-2 px-5", media ? "pt-4" : "pt-5", footer ? "pb-4" : "pb-5")}>
<div className={cx(SKELETON_BLOCK_CLASSES, "h-4 w-2/5")} />
{Array.from({ length: lineCount }, (_, i) => <div
key={i}
className={cx(SKELETON_BLOCK_CLASSES, "h-3", i === lineCount - 1 ? "w-3/5" : "w-full")}
/>)}
</div>
{footer ? <div className="flex gap-2 px-5 pb-5 pt-4">
<div className={cx(SKELETON_BLOCK_CLASSES, "h-9 w-24")} />
<div className={cx(SKELETON_BLOCK_CLASSES, "h-9 w-24")} />
</div> : null}
</div>
</Card>;
}
export { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter, CardMedia, SelectableCard, SelectableCardGroup, InteractiveCard, CardSkeleton };
export default Card; # Horizontal Card
A responsive horizontal card: media in a fixed left column with content beside it from `sm` up, collapsing to a top banner + stacked content below `sm`. Uses `CardMedia aspect="none"` with showcase-level layout classes.
## Installation
This component requires **React** and **Tailwind CSS**. Drop `code.tsx` (or `code.jsx` for JavaScript projects) into your project. Tailwind utility classes are included directly in the component, so no separate CSS file is required.
The component consumes the DevSnips semantic design tokens through Tailwind arbitrary values (for example `bg-[var(--ds-color-surface)]`). Define the `--ds-*` tokens once in your theme — see [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the full token spec.
## Usage
```tsx
import Card, {
CardMedia,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "./card-horizontal";
<Card className="overflow-hidden sm:flex-row">
<CardMedia aspect="none" className="h-40 sm:h-auto sm:w-48" src="/cover.svg" alt="Report cover" />
<div className="flex min-w-0 flex-1 flex-col">
<CardHeader>
<CardTitle>Q2 market insights</CardTitle>
<CardDescription>Quarterly analysis for the pricing team.</CardDescription>
</CardHeader>
<CardFooter className="sm:justify-between">
<p>Report · 14 pages</p>
<button>Open report</button>
</CardFooter>
</div>
</Card>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import Card, {
CardMedia,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "./card-horizontal";
<Card className="overflow-hidden sm:flex-row">
<CardMedia aspect="none" className="h-40 sm:h-auto sm:w-48" src="/cover.svg" alt="Report cover" />
<div className="flex min-w-0 flex-1 flex-col">
<CardHeader>
<CardTitle>Q2 market insights</CardTitle>
<CardDescription>Quarterly analysis for the pricing team.</CardDescription>
</CardHeader>
<CardFooter className="sm:justify-between">
<p>Report · 14 pages</p>
<button>Open report</button>
</CardFooter>
</div>
</Card>
```
## Props
### `<Card>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the surface. |
| `children` | `ReactNode` | — | Header, content, footer, and/or media regions. |
Every attribute of a plain `<div>` (`id`, `aria-*`, `data-*`, …) is forwarded. The card itself is non-interactive — use `InteractiveCard` or `SelectableCard` for click targets.
### `<CardMedia>`
| Name | Type | Default | Description |
|---|---|---|---|
| `src` | `string` | — | Image URL. Omit it to render the decorative `aria-hidden` placeholder (the layout never collapses). |
| `alt` | `string` | `""` | Alternative text; `""` marks decorative images — meaningful images must pass real alt text. |
| `aspect` | `"video" \| "square" \| "none"` | `"video"` | Crop box: 16:9, 1:1, or natural height (for fixed-size layouts like horizontal cards). |
| `className` | `string` | — | Extra classes on the media frame; the image fills it with `object-cover`. |
Every attribute of a plain `<img>` (`loading`, `sizes`, `srcSet`, …) is forwarded.
### `<CardHeader>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the header grid. |
| `children` | `ReactNode` | — | `CardTitle`, `CardDescription`, and optionally `CardAction`. |
A `grid-cols-[1fr_auto]`: title + description stack in the text column; an optional `CardAction` sits at the top of the auto-sized action column.
### `<CardTitle>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the heading. |
| `children` | `ReactNode` | — | Title text. |
A real `<h3>` — if the page outline needs a different rank, pass the heading element semantics via your page structure (the visual style stays the same).
### `<CardDescription>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the paragraph. |
| `children` | `ReactNode` | — | Supporting description text. |
### `<CardFooter>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the footer row (alignment: `sm:justify-end` / `sm:justify-between`). |
| `children` | `ReactNode` | — | Footer actions and metadata. |
Actions stack full-width below `sm` (primary last in DOM lands on top) and lay out inline from `sm` up. No baked-in justify utility, so alignment overrides never conflict.
## Composition
- `Card` — the root surface (radius-md, 1px border, surface color, shadow-xs). A plain `<div>` that only carries structure — it adds no fake interactivity.
- `CardHeader` — the header grid: title + description in a text column, an optional `CardAction` slot at the top right.
- `CardTitle` — a real `<h3>` heading (cards are page regions, so titles are headings).
- `CardDescription` — a `<p>` of muted supporting text.
- `CardAction` — the header action slot (icon buttons, a menu trigger).
- `CardContent` — the padded body region between header and footer.
- `CardFooter` — the action row; buttons stack full-width below `sm` and lay out inline from `sm` up (same recipe as the dialog footer).
- `CardMedia` — an image framed in a crop box (`video` 16:9 / `square` 1:1 / `none` natural) with graceful fallback when `src` is omitted.
- `SelectableCard` — a native `<input type="radio">` / `type="checkbox">` whose whole card is its `<label>`; controlled and uncontrolled.
- `SelectableCardGroup` — a `<fieldset>`/`<legend>` radio group owning the single selection for single-choice card pickers.
- `InteractiveCard` — a real `<a href>` when `href` is set (navigation), otherwise a real `<button type="button">` (actions).
- `CardSkeleton` — the loading placeholder: `aria-busy` + visually hidden label, reduced-motion-safe pulse.
Compose only the primitives a card actually needs — a plain `Card` with `CardContent` and no header or footer is valid.
Horizontal arrangement is layout, not a new primitive: the card root gets `className="overflow-hidden sm:flex-row"`, the media frame gets `aspect="none"` plus a fixed column width (`h-40 sm:h-auto sm:w-48`), and a flex-column wrapper holds the header/footer regions.
## Behavior
The card is `flex-col` by default; adding `sm:flex-row` places the media frame and the text wrapper side by side from the `sm` breakpoint up. `aspect="none"` removes the ratio box so the frame can be a full-height column (`sm:h-auto sm:w-48`).
Below `sm` the media renders as a fixed-height banner (`h-40`) across the top and the regions stack — content is never clipped or hidden to make space. The card's `overflow-hidden` clips the media corners to the card radius in both arrangements.
## Keyboard Interaction
The card surface itself is not focusable and carries no keyboard behavior — that is intentional, it is not a control. Every action rendered inside it (footer buttons, header icon buttons) is a native `<button>` or `<a>`, so Tab reaches it, Enter/Space activates it, and a `focus-visible` ring marks keyboard focus.
## Accessibility
- The card is a plain structural `<div>` — no fake `role="button"`, no `tabIndex` on a container, no click handlers on `<div>` elements.
- `CardTitle` renders a real `<h3>` heading, so card titles participate in the page outline.
- Actions inside the card are real native controls with visible labels or explicit `aria-label`s and `focus-visible` rings.
- State and meaning are never carried by color alone.
- Document order is media, then header/footer regions; when the layout collapses at 375px the image still precedes the text, so reading order stays intact.
## States
- **Surface** — `--ds-color-surface` with a 1px `--ds-color-border`, `radius-md`, and the restrained `shadow-xs` elevation (per the token rules: no floating-card aesthetics).
- **Title / description** — heading-md (18px, 600) on foreground; body-sm on `--ds-color-muted-foreground`.
- **Footer actions** — full-width stacked below `sm`, inline from `sm` up.
- **Disabled actions** — native `disabled`: 50% opacity, no pointer events, out of the tab order.
## Responsive Behavior
Below `sm` (640px) the media is a `h-40` banner and the regions stack full-width; from `sm` up it becomes a 192px column and the text column flexes. Verified against overflow at 375 / 768 / 1280px.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This card variant follows the token system rules: `radius-md` surfaces, 1px `color.border`, restrained `shadow-xs` elevation, heading-md titles, body-sm descriptions, and semantic status colors for trends.
## Notes
The `overflow-hidden` on the card root clips the media flush to the card border, so the frame needs no per-placement corner overrides. 539 lines UTF-8 · LF · Spaces: 2
Continue browsing