# Cards, media and composed dialogs

Native independent card actions, interactive lists, packed media and consistent scrollable dialog anatomy.

## Quick start

After the [application setup](/docs/installation/#component-consumption), copy this component into your application. It imports only public packages; the complete live demo later on this page also uses documentation layout helpers.

```tsx
import { Card, CardHeader, CardTitle, CardDescription, CardMedia, CardBody, CardActions } from '@fayegram-ai/faye-ui/core/surface';
import { Button } from '@fayegram-ai/faye-ui/controls/button';

export function PlaceCard() {
  return <Card>
    <CardMedia ratio={16 / 9}>
      <div role="img" aria-label="A geometric landscape placeholder"
        style={{ height: '100%', background: 'var(--faye-ui-color-accent-soft)' }} />
    </CardMedia>
    <CardHeader><CardTitle>Take the scenic route</CardTitle>
      <CardDescription>A gentle walk by the water.</CardDescription></CardHeader>
    <CardBody>Allow an hour and bring a good book.</CardBody>
    <CardActions><Button nativeButton={false} render={<a href="/places/scenic-route" />}>Explore this place</Button></CardActions>
  </Card>;
}
```

API reference: [Surface](/docs/api-core-surface/), [Button](/docs/api-controls-button/).

## Cards and independent actions

Card remains server-renderable. Use explicit card profiles on the root and parts: header, avatar, action, title, description, media, body, actions and primary-action. CardTitle supports semantic heading levels. CardMedia owns a positive aspect ratio and crops image/video content with object-fit. Supply intrinsic image dimensions when known.

Card owns the gap between its parts. CardFooter remains an alias for the actions region. Header avatar/action slots leave the content flexible and keep secondary actions separate. CardPrimaryAction stretches a native link or button over the card using a pseudo-element; put secondary controls in CardActions or CardHeader.action, which sit above that region. Do not wrap a card containing buttons inside another button or link.

## Lists and image compositions

ListItemButton uses native button activation and disabled behavior; selected exposes aria-pressed. Put independent controls in ListItem.trailing. ListSubheader provides an actual heading within a presentation list entry. Navigation destinations can use an ordinary link inside ListItem rather than exposing toggle semantics.

ImageList supports standard, quilted and woven layouts. Quilt item columns/rows are explicit; narrow containers reduce spans to one. ImageListItem owns a stable ratio, caption and action region. Captions may sit below the image or overlay it with a readable themed surface.

## Two masonry contracts

Masonry uses native CSS columns and column-major reading order. PackedMasonry is an opt-in client component that assigns each successive item to the shortest column without reordering DOM or tab order. It observes content, fonts, image loading and container size. Container padding and borders are preserved in both box-sizing models, including RTL layouts; row and column gaps are independent, and changing styled gaps reflows the collection even when its children are unchanged. The server output is a usable responsive grid until hydration measures it; no server-side height is guessed. Use explicit image sizes/ratios to limit movement. Avoid CSS transforms on the packing wrappers; animate content inside them.

## Dialog anatomy and ownership

Dialog and AlertDialog expose Header, Body and Actions. Popup owns the gap between these parts; Title and Description have no outside margins. A direct Body child scrolls within a bounded popup while header/actions remain visible. Use Viewport for full-document scrolling of a long dialog when that is preferable. Modal composes the same parts.

Base UI continues to own focus trapping, inert background, dismissal, nested overlay behavior and exit lifetime. Portal transports provider theme/density/direction and custom properties. Backdrop by itself is visual only; Portal by itself does not create a modal. Use the compound overlay for accessible modal behavior.

## Executable example source

```tsx
'use client';
import { useState } from 'react';
import { Bookmark, Heart, MapPin, Trash2, X, ArrowRight } from '@lucide/icons';
import { Icon } from '@fayegram-ai/faye-ui/core/icon';
import { Card, CardHeader, CardTitle, CardDescription, CardMedia, CardBody, CardActions, CardPrimaryAction, Surface } from '@fayegram-ai/faye-ui/core/surface';
import { Text, Heading } from '@fayegram-ai/faye-ui/core/text';
import { Stack, Grid, Inline } from '@fayegram-ai/faye-ui/layout/layout';
import { PackedMasonry } from '@fayegram-ai/faye-ui/display/packed-masonry';
import { ImageList, ImageListItem } from '@fayegram-ai/faye-ui/display/image-list';
import { List, ListItem, ListItemButton, ListSubheader } from '@fayegram-ai/faye-ui/display/list';
import { Avatar } from '@fayegram-ai/faye-ui/display/avatar';
import { Badge } from '@fayegram-ai/faye-ui/feedback/badge';
import { Button, IconButton } from '@fayegram-ai/faye-ui/controls/button';
import { TextField } from '@fayegram-ai/faye-ui/controls/text-field';
import { Dialog } from '@fayegram-ai/faye-ui/overlays/dialog';
import { AlertDialog } from '@fayegram-ai/faye-ui/overlays/alert-dialog';
import { ThemeScope } from '@fayegram-ai/faye-ui/provider/provider';
const subjects = ['Coastal paths','A slower morning','Hidden gardens','Weekend stories','Old town','Open water'];
const artwork = (index:number) => `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><rect width="640" height="480" fill="${['#dae8de','#ebddcd','#d4dfeb'][index%3]}"/><circle cx="490" cy="110" r="60" fill="#f6f0df"/><path d="M0 360Q160 80 320 300T640 250V480H0Z" fill="${['#597d6b','#a77c58','#627e95'][index%3]}"/><path d="M0 460Q260 210 640 400V480H0Z" fill="${['#2f5347','#70543d','#39576c'][index%3]}"/></svg>`)}`;
export default function SurfaceCompositions() {
    const [saved,setSaved] = useState(false),[selected,setSelected] = useState('Coastal paths'),[removed,setRemoved]=useState(false),[long,setLong]=useState(false);
    return <Stack gap="6" data-surface-compositions>
        <Grid columns={{base:1,md:2}} gap="4">
            <Card data-rich-card>
                <CardHeader avatar={<Avatar.Root><Avatar.Fallback>AL</Avatar.Fallback></Avatar.Root>} action={<IconButton aria-label={saved?'Unsave coastal paths':'Save coastal paths'} aria-pressed={saved} onClick={()=>setSaved(s=>!s)}><Icon icon={Bookmark}/></IconButton>}><CardTitle>Coastal paths</CardTitle><CardDescription>Curated by Alex · 6 places</CardDescription></CardHeader>
                <CardMedia ratio={4/3}><img src={artwork(0)} alt="Illustrated green hills beside the coast"/></CardMedia>
                <CardBody><Text as="p">Room to wander, a place to pause. Save a few places for your next day outside.</Text></CardBody>
                <CardActions><Badge tone="success">Ready to explore</Badge></CardActions>
                <Dialog.Root><CardTitle><Dialog.Trigger render={<CardPrimaryAction/>}>Explore coastal paths</Dialog.Trigger></CardTitle><Dialog.Portal><Dialog.Backdrop/><Dialog.Popup><Dialog.Header><Dialog.Title>Coastal paths details</Dialog.Title><Dialog.Description>A collection for slower weekends.</Dialog.Description></Dialog.Header><Dialog.Body><Text as="p">Six walks connected by open water and small cafés.</Text></Dialog.Body><Dialog.Actions><Dialog.Close render={<Button/>}>Back to discovery</Dialog.Close></Dialog.Actions></Dialog.Popup></Dialog.Portal></Dialog.Root>
            </Card>
            <Surface><Stack gap="4"><Heading level={3}>Your collections</Heading><List aria-label="Collections"><ListSubheader>Saved for later</ListSubheader>{subjects.slice(0,3).map((title,i)=><ListItem key={title} leading={<Icon icon={MapPin}/>} trailing={<IconButton aria-label={`More about ${title}`} onClick={()=>setSelected(title)}><Icon icon={ArrowRight}/></IconButton>}><ListItemButton selected={selected===title} disabled={i===2} onClick={()=>setSelected(title)}>{title}</ListItemButton></ListItem>)}</List><Text data-list-result>{selected}</Text><Button onClick={()=>setLong(v=>!v)}>Toggle longer masonry content</Button></Stack></Surface>
        </Grid>
        <ThemeScope scheme="dark" variables={{'--faye-ui-radius-overlay':'18px'}}>
            <Dialog.Root><Dialog.Trigger render={<Button/>}>Open composed collection dialog</Dialog.Trigger><Dialog.Portal><Dialog.Backdrop/><Dialog.Popup variants={{size:"lg"}} data-composed-dialog><Dialog.Header><Dialog.Title>A long collection editor</Dialog.Title><Dialog.Close render={<IconButton aria-label="Close collection editor"><Icon icon={X}/></IconButton>}/><Dialog.Description>Header and actions remain reachable while the content scrolls.</Dialog.Description></Dialog.Header><Dialog.Body><Stack gap="4"><TextField label="Collection heading" defaultValue="Weekend stories"/>{Array.from({length:14},(_,i)=><Text as="p" key={i}>Place {i+1}: A longer description with room for a meaningful name and details, without changing the surrounding frame.</Text>)}<AlertDialog.Root><AlertDialog.Trigger render={<Button tone="danger"/>}>Remove nested collection</AlertDialog.Trigger><AlertDialog.Portal><AlertDialog.Backdrop/><AlertDialog.Popup><AlertDialog.Header><AlertDialog.Title>Remove this collection?</AlertDialog.Title><AlertDialog.Description>Your original places stay available.</AlertDialog.Description></AlertDialog.Header><AlertDialog.Body><Text as="p">Only the saved grouping is removed in this local example.</Text></AlertDialog.Body><AlertDialog.Actions><AlertDialog.Close render={<Button/>}>Keep collection</AlertDialog.Close><AlertDialog.Close render={<Button tone="danger"/>} onClick={()=>setRemoved(true)}><Icon icon={Trash2}/>Remove collection</AlertDialog.Close></AlertDialog.Actions></AlertDialog.Popup></AlertDialog.Portal></AlertDialog.Root><Text role="status">{removed?'Collection removed.':'Collection retained.'}</Text></Stack></Dialog.Body><Dialog.Actions><Dialog.Close render={<Button/>}>Finish editing collection</Dialog.Close></Dialog.Actions></Dialog.Popup></Dialog.Portal></Dialog.Root>
        </ThemeScope>
        {(['standard','quilted','woven'] as const).map(variant=><Stack gap="3" key={variant}><Heading level={3}>{variant} image list</Heading><ImageList variant={variant} data-media-variant={variant}>{subjects.slice(0,4).map((subject,i)=><ImageListItem key={subject} columns={i===0?2:1} rows={i===0?2:1} caption={subject} captionPosition={i%2?'below':'overlay'} actions={<IconButton aria-label={`Like ${variant} ${subject}`} variant="ghost" onClick={event=>event.currentTarget.setAttribute('aria-pressed',event.currentTarget.getAttribute('aria-pressed')==='true'?'false':'true')} aria-pressed={false}><Icon icon={Heart}/></IconButton>}><img src={artwork(i)} alt={`${subject} landscape illustration`}/></ImageListItem>)}</ImageList></Stack>)}
        <Stack gap="3"><Heading level={3}>Packed collections</Heading><PackedMasonry columns={{base:1,sm:2,lg:3}} gap="4" data-packed-media>{subjects.map((subject,i)=><Card key={subject}><CardTitle>{subject}</CardTitle><CardMedia ratio={i%2?1:1.8}><img src={artwork(i)} alt=""/></CardMedia><CardBody><Text as="p">{i===0&&long?'A much longer story. '.repeat(20):'A small collection for a new perspective.'}</Text></CardBody><CardActions><Button onClick={()=>setSelected(subject)}>Choose {subject}</Button></CardActions></Card>)}</PackedMasonry></Stack>
    </Stack>;
}
```
