Welcome Email
Edited 2 hours ago
Card for displaying email templates with thumbnail, status, and actions.
Edited 2 hours ago
Edited yesterday
Created just now
Archived 3 months ago
Edited last week
Edited 5 days ago
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>
);
} '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 };
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>
Root container. Renders as an <article> element wrapped in a Surface.
| Prop | Type | Default | Description |
|---|---|---|---|
size | 'sm' | 'md' | 'md' | Controls thumbnail aspect ratio and spacing |
density | 'comfortable' | 'compact' | 'comfortable' | Controls internal padding |
selected | boolean | - | Applies selected ring styling |
onClick | function | - | Makes the card interactive (adds role=“button”) |
When onClick is provided, the card becomes keyboard-activatable with Enter or Space.
Displays the template preview image. If no src is provided, renders a placeholder with an icon.
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | - | Image source URL |
alt | string | '' | Alt text for the image |
Aspect ratio is 4:3 for md size, 3:2 for sm size.
Container for title and meta information. Use this to wrap Title and Meta.
Template name. Renders as an <h3>. Truncates to one line.
Secondary information like timestamps. Renders as a <p>. Truncates to one line.
Displays a status badge.
| Prop | Type | Default | Description |
|---|---|---|---|
status | 'draft' | 'published' | 'scheduled' | 'archived' | - | Status type |
children | ReactNode | - | Override the default label |
Status to Badge variant mapping:
draft → neutralpublished → successscheduled → infoarchived → warningPositioned absolutely in the top-right corner. Use for overflow menus or action buttons.
<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>
<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>
<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>
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.
When onClick is provided, the card is focusable and activates on Enter or Space. Ensure your click handler works correctly when triggered via keyboard.
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