Agents (llms.txt)

TemplateCard

Card for displaying email templates with thumbnail, status, and actions.

Welcome Email

Edited 2 hours ago

Published
Newsletter preview

Weekly Newsletter

Edited yesterday

Scheduled

New Template

Created just now

Draft
Old campaign preview

Q3 Campaign

Archived 3 months ago

Archived
Promo email preview

Flash Sale Promo

Edited last week

Published
Onboarding email preview

Onboarding Series

Edited 5 days ago

Draft
import {
  Copy01,
  DotsHorizontal,
  Edit03,
  Trash01,
} from '@untitledui-pro/icons/solid';
import { useState } from 'react';

import { Button } from '@/components/button';
import { Menu } from '@/components/menu';
import { TemplateCard } from '@/components/template-card';

export const meta = {
  layout: 'padded',
};

export default function TemplateCardPreview() {
  const [selectedId, setSelectedId] = useState<string | null>('2');

  return (
    <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
      {/* Default with thumbnail */}
      <TemplateCard
        onClick={() => setSelectedId('1')}
        selected={selectedId === '1'}
      >
        <TemplateCard.Body>
          <TemplateCard.Title>Welcome Email</TemplateCard.Title>
          <TemplateCard.Meta>Edited 2 hours ago</TemplateCard.Meta>
        </TemplateCard.Body>
        <TemplateCard.Status status="published" />
        <TemplateCard.Actions>
          <Menu>
            <Menu.Trigger>
              <Button
                variant="ghost"
                size="sm"
                square
                aria-label="Template actions"
                onClick={(e: React.MouseEvent) => e.stopPropagation()}
              >
                <DotsHorizontal />
              </Button>
            </Menu.Trigger>
            <Menu.Items>
              <Menu.Item>
                <Edit03 />
                Edit
              </Menu.Item>
              <Menu.Item>
                <Copy01 />
                Duplicate
              </Menu.Item>
              <Menu.Divider />
              <Menu.Item variant="destructive">
                <Trash01 />
                Delete
              </Menu.Item>
            </Menu.Items>
          </Menu>
        </TemplateCard.Actions>
      </TemplateCard>

      {/* Selected state */}
      <TemplateCard
        onClick={() => setSelectedId('2')}
        selected={selectedId === '2'}
      >
        <TemplateCard.Thumbnail
          src="https://images.unsplash.com/photo-1563986768494-4dee2763ff3f?w=400&h=300&fit=crop"
          alt="Newsletter preview"
        />
        <TemplateCard.Body>
          <TemplateCard.Title>Weekly Newsletter</TemplateCard.Title>
          <TemplateCard.Meta>Edited yesterday</TemplateCard.Meta>
        </TemplateCard.Body>
        <TemplateCard.Status status="scheduled" />
      </TemplateCard>

      {/* No thumbnail (placeholder) */}
      <TemplateCard
        onClick={() => setSelectedId('3')}
        selected={selectedId === '3'}
      >
        <TemplateCard.Thumbnail />
        <TemplateCard.Body>
          <TemplateCard.Title>New Template</TemplateCard.Title>
          <TemplateCard.Meta>Created just now</TemplateCard.Meta>
        </TemplateCard.Body>
        <TemplateCard.Status status="draft" />
      </TemplateCard>

      {/* Archived status */}
      <TemplateCard
        onClick={() => setSelectedId('4')}
        selected={selectedId === '4'}
      >
        <TemplateCard.Thumbnail
          src="https://images.unsplash.com/photo-1557200134-90327ee9fafa?w=400&h=300&fit=crop"
          alt="Old campaign preview"
        />
        <TemplateCard.Body>
          <TemplateCard.Title>Q3 Campaign</TemplateCard.Title>
          <TemplateCard.Meta>Archived 3 months ago</TemplateCard.Meta>
        </TemplateCard.Body>
        <TemplateCard.Status status="archived" />
      </TemplateCard>

      {/* Small size variant */}
      <TemplateCard
        size="sm"
        onClick={() => setSelectedId('5')}
        selected={selectedId === '5'}
      >
        <TemplateCard.Thumbnail
          src="https://images.unsplash.com/photo-1611532736597-de2d4265fba3?w=400&h=300&fit=crop"
          alt="Promo email preview"
        />
        <TemplateCard.Body>
          <TemplateCard.Title>Flash Sale Promo</TemplateCard.Title>
          <TemplateCard.Meta>Edited last week</TemplateCard.Meta>
        </TemplateCard.Body>
        <TemplateCard.Status status="published" />
      </TemplateCard>

      <TemplateCard
        onClick={() => setSelectedId('6')}
        selected={selectedId === '6'}
      >
        <TemplateCard.Thumbnail
          src="https://images.unsplash.com/photo-1586281380349-632531db7ed4?w=400&h=300&fit=crop"
          alt="Onboarding email preview"
        />
        <TemplateCard.Body>
          <TemplateCard.Title>Onboarding Series</TemplateCard.Title>
          <TemplateCard.Meta>Edited 5 days ago</TemplateCard.Meta>
        </TemplateCard.Body>
        <TemplateCard.Status status="draft" />
      </TemplateCard>
    </div>
  );
}

Dependencies

Source Code

'use client';

import { Image01 } from '@untitledui-pro/icons/solid';
import type { VariantProps } from 'cva';
import React, { use, useCallback } from 'react';

import { Badge } from '@/components/badge';
import { Surface } from '@/components/surface';
import { cn, cva } from '@/lib/utils/classnames';

const templateCardStyle = cva({
  base: [
    'group relative flex flex-col gap-0 overflow-hidden',
    'transition-[transform,background-color,box-shadow] duration-(--duration-hover) ease-(--ease)',
  ],
  variants: {
    size: {
      sm: '',
      md: '',
    },
  },
  defaultVariants: {
    size: 'md',
  },
});

interface TemplateCardContextValue {
  size: 'sm' | 'md';
}

const TemplateCardContext = React.createContext<TemplateCardContextValue>({
  size: 'md',
});

function useTemplateCardContext() {
  return use(TemplateCardContext);
}

interface TemplateCardProps
  extends React.ComponentPropsWithRef<'article'>,
    VariantProps<typeof templateCardStyle> {
  selected?: boolean;
}

const TemplateCard = ({
  ref,
  className,
  children,
  size = 'md',
  selected,
  onClick,
  onKeyDown,
  ...rest
}: TemplateCardProps) => {
  const isInteractive = !!onClick;

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLElement>) => {
      if (onClick && (e.key === 'Enter' || e.key === ' ')) {
        e.preventDefault();
        onClick(e as unknown as React.MouseEvent<HTMLElement>);
      }
      onKeyDown?.(e);
    },
    [onClick, onKeyDown]
  );

  return (
    <TemplateCardContext value={{ size: size ?? 'md' }}>
      <Surface
        asChild
        elevation="raised"
        radius="md"
        padding="none"
        interactive={isInteractive}
      >
        <article
          ref={ref}
          role={isInteractive ? 'button' : undefined}
          tabIndex={isInteractive ? 0 : undefined}
          data-selected={selected || undefined}
          onClick={onClick}
          onKeyDown={isInteractive ? handleKeyDown : onKeyDown}
          className={cn(
            templateCardStyle({ size }),
            isInteractive && [
              'cursor-pointer',
              'active:scale-(--press-scale)',
              'focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2',
            ],
            'data-selected:ring-2 data-selected:ring-accent',
            className
          )}
          {...rest}
        >
          {children}
        </article>
      </Surface>
    </TemplateCardContext>
  );
};

const thumbnailStyle = cva({
  base: 'w-full rounded-md bg-surface-sunken object-cover',
  variants: {
    size: {
      sm: 'aspect-[3/2]',
      md: 'aspect-[4/3]',
    },
  },
  defaultVariants: {
    size: 'md',
  },
});

interface TemplateCardThumbnailProps
  extends React.ComponentPropsWithRef<'img'> {}

const TemplateCardThumbnail = ({
  ref,
  className,
  src,
  alt = '',
  ...rest
}: TemplateCardThumbnailProps) => {
  const { size } = useTemplateCardContext();

  if (!src) {
    return (
      <div
        className={cn(
          thumbnailStyle({ size }),
          'flex items-center justify-center',
          className
        )}
      >
        <Image01 className="size-10 text-foreground/30" aria-hidden />
      </div>
    );
  }

  return (
    <img
      ref={ref}
      src={src}
      alt={alt}
      className={cn(thumbnailStyle({ size }), className)}
      {...rest}
    />
  );
};

interface TemplateCardBodyProps extends React.ComponentPropsWithRef<'div'> {}

const TemplateCardBody = ({
  ref,
  className,
  children,
  ...rest
}: TemplateCardBodyProps) => {
  const { size } = useTemplateCardContext();
  return (
    <div
      ref={ref}
      className={cn(
        'flex min-w-0 flex-1 flex-col gap-0.5',
        size === 'sm' ? 'p-3' : 'p-4',
        className
      )}
      {...rest}
    >
      {children}
    </div>
  );
};

interface TemplateCardTitleProps extends React.ComponentPropsWithRef<'h3'> {}

const TemplateCardTitle = ({
  ref,
  className,
  children,
  ...rest
}: TemplateCardTitleProps) => (
  <h3
    ref={ref}
    className={cn('truncate font-medium text-foreground', className)}
    {...rest}
  >
    {children}
  </h3>
);

interface TemplateCardMetaProps extends React.ComponentPropsWithRef<'p'> {}

const TemplateCardMeta = ({
  ref,
  className,
  children,
  ...rest
}: TemplateCardMetaProps) => (
  <p
    ref={ref}
    className={cn('truncate text-foreground/60 text-sm', className)}
    {...rest}
  >
    {children}
  </p>
);

type TemplateStatus = 'draft' | 'published' | 'scheduled' | 'archived';

const statusVariantMap: Record<
  TemplateStatus,
  'neutral' | 'success' | 'info' | 'warning'
> = {
  draft: 'neutral',
  published: 'success',
  scheduled: 'info',
  archived: 'warning',
};

const statusLabelMap: Record<TemplateStatus, string> = {
  draft: 'Draft',
  published: 'Published',
  scheduled: 'Scheduled',
  archived: 'Archived',
};

interface TemplateCardStatusProps
  extends Omit<React.ComponentPropsWithRef<'div'>, 'children'> {
  status: TemplateStatus;
  children?: React.ReactNode;
}

const TemplateCardStatus = ({
  status,
  children,
  className,
  ...rest
}: TemplateCardStatusProps) => {
  const variant = statusVariantMap[status];
  const label = children ?? statusLabelMap[status];

  return (
    <Badge
      variant={variant}
      size="sm"
      className={cn(
        'absolute top-4 right-4 z-10 w-max bg-surface-overlay',
        className
      )}
      {...rest}
    >
      {label}
    </Badge>
  );
};

interface TemplateCardActionsProps extends React.ComponentPropsWithRef<'div'> {}

const TemplateCardActions = ({
  ref,
  className,
  children,
  ...rest
}: TemplateCardActionsProps) => (
  <div
    ref={ref}
    className={cn('pointer-events-auto absolute top-2 right-2 z-10', className)}
    {...rest}
  >
    {children}
  </div>
);

const CompoundTemplateCard = Object.assign(TemplateCard, {
  Thumbnail: TemplateCardThumbnail,
  Body: TemplateCardBody,
  Title: TemplateCardTitle,
  Meta: TemplateCardMeta,
  Status: TemplateCardStatus,
  Actions: TemplateCardActions,
});

export { CompoundTemplateCard as TemplateCard };

Usage


          import { TemplateCard } from '@/foundations/ui/template-card/template-card';

<TemplateCard onClick={handleSelect} selected={isSelected}>
  <TemplateCard.Thumbnail src="/preview.png" alt="Template preview" />
  <TemplateCard.Body>
    <TemplateCard.Title>Welcome Email</TemplateCard.Title>
    <TemplateCard.Meta>Edited 2 hours ago</TemplateCard.Meta>
  </TemplateCard.Body>
  <TemplateCard.Status status="published" />
  <TemplateCard.Actions>
    <Menu>...</Menu>
  </TemplateCard.Actions>
</TemplateCard>
        

API Reference

TemplateCard

Root container. Renders as an <article> element wrapped in a Surface.

PropTypeDefaultDescription
size'sm' | 'md''md'Controls thumbnail aspect ratio and spacing
density'comfortable' | 'compact''comfortable'Controls internal padding
selectedboolean-Applies selected ring styling
onClickfunction-Makes the card interactive (adds role=“button”)

When onClick is provided, the card becomes keyboard-activatable with Enter or Space.

TemplateCard.Thumbnail

Displays the template preview image. If no src is provided, renders a placeholder with an icon.

PropTypeDefaultDescription
srcstring-Image source URL
altstring''Alt text for the image

Aspect ratio is 4:3 for md size, 3:2 for sm size.

TemplateCard.Body

Container for title and meta information. Use this to wrap Title and Meta.

TemplateCard.Title

Template name. Renders as an <h3>. Truncates to one line.

TemplateCard.Meta

Secondary information like timestamps. Renders as a <p>. Truncates to one line.

TemplateCard.Status

Displays a status badge.

PropTypeDefaultDescription
status'draft' | 'published' | 'scheduled' | 'archived'-Status type
childrenReactNode-Override the default label

Status to Badge variant mapping:

  • draftneutral
  • publishedsuccess
  • scheduledinfo
  • archivedwarning

TemplateCard.Actions

Positioned absolutely in the top-right corner. Use for overflow menus or action buttons.

Examples

With Menu Actions


          <TemplateCard onClick={handleSelect}>
  <TemplateCard.Thumbnail src="/preview.png" />
  <TemplateCard.Body>
    <TemplateCard.Title>Welcome Email</TemplateCard.Title>
    <TemplateCard.Meta>Edited 2 hours ago</TemplateCard.Meta>
  </TemplateCard.Body>
  <TemplateCard.Status status="published" />
  <TemplateCard.Actions>
    <Menu>
      <Menu.Trigger>
        <IconButton
          variant="ghost"
          size="sm"
          aria-label="Template actions"
          onClick={(e) => e.stopPropagation()}
        >
          <DotsThree weight="bold" />
        </IconButton>
      </Menu.Trigger>
      <Menu.Content>
        <Menu.Item>Edit</Menu.Item>
        <Menu.Item>Duplicate</Menu.Item>
        <Menu.Item variant="danger">Delete</Menu.Item>
      </Menu.Content>
    </Menu>
  </TemplateCard.Actions>
</TemplateCard>
        

Without Thumbnail


          <TemplateCard onClick={handleSelect}>
  <TemplateCard.Thumbnail />
  <TemplateCard.Body>
    <TemplateCard.Title>New Template</TemplateCard.Title>
    <TemplateCard.Meta>Created just now</TemplateCard.Meta>
  </TemplateCard.Body>
  <TemplateCard.Status status="draft" />
</TemplateCard>
        

Small Size


          <TemplateCard size="sm" onClick={handleSelect}>
  <TemplateCard.Thumbnail src="/preview.png" />
  <TemplateCard.Body>
    <TemplateCard.Title>Flash Sale</TemplateCard.Title>
    <TemplateCard.Meta>Edited last week</TemplateCard.Meta>
  </TemplateCard.Body>
  <TemplateCard.Status status="published" />
</TemplateCard>
        

Best Practices

When using a Menu inside TemplateCard.Actions, add onClick={(e) => e.stopPropagation()} to the trigger button. Otherwise, clicking the menu trigger will also trigger the card’s onClick handler.


          <IconButton onClick={(e) => e.stopPropagation()}>
  <DotsThree />
</IconButton>
        

The component does not automatically stop propagation on the Actions slot because you may want some actions (like a favorite toggle) to propagate intentionally.

Keyboard Navigation

When onClick is provided, the card is focusable and activates on Enter or Space. Ensure your click handler works correctly when triggered via keyboard.

Selection State

Use the selected prop to indicate the currently selected card in a list. This adds a visible ring around the card.


          <TemplateCard selected={selectedId === template.id} onClick={() => setSelectedId(template.id)}>
  ...
</TemplateCard>
        

Previous

Tabs

Next

Textarea