# Story card (Caseroom component)

Picture, kicker, the title as the one link, a line and the meta; standard, compact, wide and feature. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

74% of the 2,062 sites in The UI Index have a card (1,534 sites).

## Install

- React: `npx shadcn@latest add https://www.themes.dev/caseroom/r/story-card.json`
- Astro: `npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/story-card.json`
- HTML: https://www.themes.dev/caseroom/r/html/story-card.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/story-card/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### story-card.astro

```astro
---
import { cn } from '@/lib/utils';
import { story, storyCard, type StoryContent, type StoryVariant } from './story-card.variants';

type Props = StoryContent & { variant?: StoryVariant; class?: string };
const { title, href, kicker, description, meta, image, variant = 'standard', class: className } = Astro.props;
---

<article class={cn(storyCard({ variant }), className)}>
  {image && <div class={cn(story.media, story.mediaRatio[variant])}><img src={image.src} alt={image.alt} loading="lazy" decoding="async" class={story.img} /></div>}
  <div class={story.body[variant]}>
    {kicker && <p class={story.kicker}>{kicker}</p>}
    <h3 class={story.title[variant]}><a href={href} class={story.link}>{title}</a></h3>
    {description && variant !== 'compact' && <p class={story.description}>{description}</p>}
    {meta && <p class={story.meta}>{meta}</p>}
  </div>
</article>
```

### story-card.tsx

```tsx
import { cn } from '@/lib/utils';
import { story, storyCard, type StoryContent, type StoryVariant } from './story-card.variants';

export type StoryCardProps = StoryContent & { variant?: StoryVariant; className?: string };

/** A story card: picture, kicker, the title as the one link (the whole card is the target), a line and the meta. */
export function StoryCard({ title, href, kicker, description, meta, image, variant = 'standard', className }: StoryCardProps) {
  return (
    <article className={cn(storyCard({ variant }), className)}>
      {image && <div className={cn(story.media, story.mediaRatio[variant])}><img src={image.src} alt={image.alt} loading="lazy" decoding="async" className={story.img} /></div>}
      <div className={story.body[variant]}>
        {kicker && <p className={story.kicker}>{kicker}</p>}
        <h3 className={story.title[variant]}><a href={href} className={story.link}>{title}</a></h3>
        {description && variant !== 'compact' && <p className={story.description}>{description}</p>}
        {meta && <p className={story.meta}>{meta}</p>}
      </div>
    </article>
  );
}
```

### story-card.variants.ts

```tsx
import { cva, type VariantProps } from 'class-variance-authority';

/** A story card (the most common card in The UI Index): a picture, a kicker (the topic, in words), a title that is
 *  the link, a line, and the meta. The whole card is the target (the title's link stretches over it), so there is
 *  one link per card. standard: picture on top. compact: a small picture beside the text. wide: picture beside the
 *  text from a wide container. feature: a larger title, for the lead story. */
export const storyCard = cva('group relative isolate grid overflow-hidden rounded-xl border bg-card text-card-foreground transition-[border-color,box-shadow] hover:border-foreground/30 hover:shadow-md has-[a:active]:bg-pressed/40 @container', {
  variants: {
    variant: {
      standard: '',
      compact: 'grid-cols-[auto_1fr] items-center gap-0',
      wide: '@xl:grid-cols-[1.2fr_1fr]',
      feature: '',
    },
  },
  defaultVariants: { variant: 'standard' },
});
export const story = {
  media: 'relative overflow-hidden bg-muted',
  mediaRatio: { standard: 'aspect-[16/9]', compact: 'my-3 ml-3 size-20 shrink-0 rounded-lg border', wide: 'aspect-[16/9] @xl:aspect-auto @xl:h-full', feature: 'aspect-[16/9]' },
  img: 'size-full object-cover transition-transform duration-500 group-hover:scale-[1.03] motion-reduce:transition-none',
  body: { standard: 'flex flex-col gap-2 p-5', compact: 'flex flex-col gap-1 p-4', wide: 'flex flex-col justify-center gap-2 p-5 @xl:p-8', feature: 'flex flex-col gap-3 p-6' },
  kicker: 'text-xs font-medium text-muted-foreground',
  title: { standard: 'text-lg leading-snug font-semibold tracking-[-0.02em] text-balance', compact: 'text-sm leading-snug font-semibold text-balance', wide: 'text-xl leading-snug font-semibold tracking-[-0.02em] text-balance', feature: 'text-2xl leading-tight font-semibold tracking-[-0.03em] text-balance @md:text-3xl' },
  // The title's link stretches over the card (after:inset-0); the focus ring stays on the title itself.
  link: 'rounded-sm underline decoration-transparent underline-offset-4 transition-[text-decoration-color] after:absolute after:inset-0 after:z-10 after:rounded-xl group-hover:decoration-foreground/40 hover:decoration-foreground active:bg-accent active:decoration-2',
  description: 'text-sm text-pretty text-muted-foreground',
  meta: 'mt-auto pt-2 text-xs text-muted-foreground tabular-nums',
};
export type StoryVariant = NonNullable<VariantProps<typeof storyCard>['variant']>;
export type StoryContent = { title: string; href: string; kicker?: string; description?: string; meta?: string; image?: { src: string; alt: string } };
```
