Skip to content

Empty state

Use after a list, a dashboard tile, or a search result page when there’s nothing yet. Empty states are an under-loved UX surface; small details here pay back in retention.

'use client';
import type { ReactNode } from 'react';
import { Button, Card, Stack, Text } from '@helixui/core';
import { Inbox } from '@helixui/icons';
export interface EmptyBlockProps {
/** Icon — defaults to an Inbox glyph. Pass null for none. */
icon?: ReactNode;
title: string;
description?: ReactNode;
actionLabel?: string;
onAction?: () => void;
/** Subtle secondary action, often "Learn more". */
secondaryLabel?: string;
onSecondary?: () => void;
}
export function EmptyBlock({
icon = <Inbox />,
title,
description,
actionLabel,
onAction,
secondaryLabel,
onSecondary,
}: EmptyBlockProps) {
return (
<Card variant="outlined" style={{ padding: 'var(--helixui-space-8)' }}>
<Stack gap={4} align="center" style={{ textAlign: 'center' }}>
{icon ? (
<div
style={{
width: 48,
height: 48,
display: 'grid',
placeItems: 'center',
borderRadius: 'var(--helixui-radius-lg)',
background: 'var(--helixui-color-bg-surface-subtle)',
color: 'var(--helixui-color-text-muted)',
}}
aria-hidden
>
{icon}
</div>
) : null}
<Stack gap={1} align="center">
<Text size="lg" weight="semibold">{title}</Text>
{description ? (
<Text size="sm" tone="muted" style={{ maxWidth: 360 }}>{description}</Text>
) : null}
</Stack>
{(actionLabel || secondaryLabel) ? (
<Stack direction="row" gap={2}>
{secondaryLabel ? (
<Button variant="ghost" tone="neutral" onClick={onSecondary}>{secondaryLabel}</Button>
) : null}
{actionLabel ? <Button onClick={onAction}>{actionLabel}</Button> : null}
</Stack>
) : null}
</Stack>
</Card>
);
}

Variants

// No content yet:
<EmptyBlock
title="No invoices yet"
description="Once you generate your first invoice, it'll show up here."
actionLabel="Create invoice"
onAction={openNewInvoice}
/>
// Search came back empty:
<EmptyBlock
title="No matches"
description="Try a broader query or clear your filters."
secondaryLabel="Clear filters"
onSecondary={clearFilters}
/>
// Permission denied:
<EmptyBlock
title="Access denied"
description="You don't have permission to view this. Ask an admin to grant access."
actionLabel="Request access"
onAction={requestAccess}
/>

Design notes

  • One primary action max. If the user has multiple things to try, the copy in description carries them; only the most likely goes in the button.
  • The icon is in a contained badge. Naked icons in empty states age poorly because they fight with the typography for attention.