Skip to content
    Style: Wireframe

    Components · Display

    Story card

    Picture, kicker, the title as the one link, a line and the meta; standard, compact, wide and feature.

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

    Used in UI Pro: Posts, Work

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    Install

    React
    npx shadcn@latest add https://www.themes.dev/ui/r/story-card.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/story-card.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI theme. The HTML needs only the compiled CSS.

    story-card.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
    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 } };