Callout boxes
Notes in a text in four tones, information, tip, warning and success. As a soft panel, side line, foldable note or large panel.
Notes that stand out at the right moment
A callout lifts a note out of the running text, a tip, a warning or the confirmation that something is done. All four versions come in four tones, info, tip, warning and success, each with its own icon and color, so the meaning reads at a glance.
Soft panel is a tinted panel with icon, title, text and a link in the color of its tone. Side line is a calm line in the tone color with a label, for texts with several notes. Foldable note opens a title into the details, for asides that would otherwise interrupt the text. Featured with steps is a large panel with an icon tile and numbered steps, for the one note a page is about.
Use warnings sparingly, then they keep their weight. Write the title as the takeaway, Back up first says more than Note. The featured panel fits once per page, side lines can repeat.
Details
What Callout brings along, read from the registry and the source.
- Category
- Article elements
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/callout.tsx
- Brings along
- utilskiticonsection
- npm packages
- None
- Languages
- English and German, 6 texts of its own
- Studio
- Adjust in code
- In collections
- Blog or magazine
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CalloutProps interface from components/ui/callout.tsx, with the comments from the code.
variantoptionalCalloutVariantsoftaccent-barcollapsiblefeatured
toneoptionalCalloutToneinfotipwarningsuccess
titlerequiredstringtextoptionalstring | string[]One or more paragraphs
pointsoptionalstring[]featured: numbered steps, soft and accent-bar: a short list
actionoptional{ label: string; href: string }iconoptionalIconNameOverrides the icon of the tone
defaultOpenoptionalbooleancollapsible: open from the start
contextoptional{ before?: string[]; after?: string[] }Paragraphs before and after the note, to show it inside a text
Built-in texts
Callout writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 6 texts
| Key | English | German |
|---|---|---|
| info | English Information | German Information |
| tip | English Tip | German Tipp |
| warning | English Warning | German Achtung |
| success | English Done | German Erledigt |
| more | English Show details | German Details zeigen |
| less | English Hide details | German Details ausblenden |
Accessibility
Read from the source of the component.
- Sets aria-controls, aria-expanded and aria-label, so screen readers announce purpose and state.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 283 lines
'use client';
import { useId, useState } from 'react';
import { cn, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Section } from '@/components/ui/section';
/*
* Notes inside a text that the eye finds while skimming: information, a tip,
* a warning or a success, each with its own icon and color.
*
* soft a tinted panel with icon, title, text and a link
* accent-bar a calm line on the side, for many notes in one text
* collapsible a title that opens into the details, for asides
* featured a large panel with an icon tile and numbered steps
*
* `context` adds paragraphs before and after, so the note sits in a text.
* Without it the note stands alone, ready for your own article.
*/
export type CalloutVariant = 'soft' | 'accent-bar' | 'collapsible' | 'featured';
export type CalloutTone = 'info' | 'tip' | 'warning' | 'success';
const TEXTS = {
en: { info: 'Information', tip: 'Tip', warning: 'Warning', success: 'Done', more: 'Show details', less: 'Hide details' },
de: { info: 'Information', tip: 'Tipp', warning: 'Achtung', success: 'Erledigt', more: 'Details zeigen', less: 'Details ausblenden' },
};
type Texts = (typeof TEXTS)['en'];
export interface CalloutProps {
variant?: CalloutVariant;
tone?: CalloutTone;
title: string;
/** One or more paragraphs */
text?: string | string[];
/** featured: numbered steps, soft and accent-bar: a short list */
points?: string[];
action?: { label: string; href: string };
/** Overrides the icon of the tone */
icon?: IconName;
/** collapsible: open from the start */
defaultOpen?: boolean;
/** Paragraphs before and after the note, to show it inside a text */
context?: { before?: string[]; after?: string[] };
labels?: Partial<Texts>;
className?: string;
}
/* Tones: an icon, a text color readable in both modes and a tinted surface */
const TONE: Record<CalloutTone, { icon: IconName; text: string; ink: string; surface: string; line: string; tile: string }> = {
info: {
icon: 'info',
text: 'text-info',
ink: 'text-[color-mix(in_oklab,var(--info)_80%,var(--foreground))]',
surface: 'bg-[color-mix(in_oklab,var(--info)_9%,var(--card))]',
line: 'border-[color-mix(in_oklab,var(--info)_30%,var(--border))]',
tile: 'bg-[color-mix(in_oklab,var(--info)_16%,var(--card))]',
},
tip: {
icon: 'lightbulb',
text: 'text-primary',
ink: 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]',
surface: 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]',
line: 'border-[color-mix(in_oklab,var(--primary)_30%,var(--border))]',
tile: 'bg-[color-mix(in_oklab,var(--primary)_15%,var(--card))]',
},
warning: {
icon: 'alert',
text: 'text-warning-foreground',
ink: 'text-warning-foreground',
surface: 'bg-[color-mix(in_oklab,var(--warning)_13%,var(--card))]',
line: 'border-[color-mix(in_oklab,var(--warning)_45%,var(--border))]',
tile: 'bg-[color-mix(in_oklab,var(--warning)_22%,var(--card))]',
},
success: {
icon: 'check-circle',
text: 'text-success-foreground',
ink: 'text-success-foreground',
surface: 'bg-[color-mix(in_oklab,var(--success)_10%,var(--card))]',
line: 'border-[color-mix(in_oklab,var(--success)_35%,var(--border))]',
tile: 'bg-[color-mix(in_oklab,var(--success)_17%,var(--card))]',
},
};
const BAR: Record<CalloutTone, string> = { info: 'bg-info', tip: 'bg-primary', warning: 'bg-warning', success: 'bg-success' };
const paras = (text?: string | string[]) => (text === undefined ? [] : Array.isArray(text) ? text : [text]);
function Link({ action, className }: { action?: CalloutProps['action']; className?: string }) {
if (!action) return null;
return (
<a
href={action.href}
className={cn(
'group/link inline-flex min-h-11 items-center gap-1.5 text-[14px] font-medium text-foreground underline decoration-border-strong underline-offset-4 transition-colors hover:decoration-foreground outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring',
className,
)}
>
{action.label}
<Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover/link:translate-x-0.5" />
</a>
);
}
function Points({ points, tone }: { points?: string[]; tone: CalloutTone }) {
if (!points?.length) return null;
return (
<ul className="mt-3 space-y-1.5">
{points.map((pt) => (
<li key={pt} className="flex gap-2.5 text-[15px] leading-relaxed text-muted-foreground">
<span aria-hidden className={cn('mt-[0.7em] size-1.5 shrink-0 rounded-full', BAR[tone])} />
<span>{pt}</span>
</li>
))}
</ul>
);
}
/** The note rises in once and its icon gives a small turn, nothing more */
function useEntrance() {
const [ref, inView] = useInView<HTMLElement>('0px 0px -10% 0px');
return { ref, cls: cn('transition-[opacity,transform] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'translate-y-3 opacity-0'), icon: cn('transition-transform delay-200 duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && '-rotate-12 scale-75') };
}
/* ------------------------------------------------------------------ */
function Soft(p: CalloutProps & { t: Texts }) {
const tone = p.tone ?? 'info';
const k = TONE[tone];
const e = useEntrance();
return (
<aside ref={e.ref} aria-label={p.t[tone]} className={cn('rounded-xl border p-5 sm:p-6', k.surface, k.line, e.cls)}>
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4">
<span className={cn('mt-0.5 flex size-8 items-center justify-center rounded-lg', k.tile, k.text)}>
<Icon name={p.icon ?? k.icon} size={17} className={e.icon} />
</span>
<div>
<p className={cn('pt-[0.3rem] text-[15.5px] font-semibold leading-snug', k.ink)}>{p.title}</p>
{paras(p.text).map((t, i) => (
<p key={i} className="mt-1.5 text-pretty text-[15px] leading-relaxed text-[color-mix(in_oklab,var(--foreground)_78%,var(--background))]">
{t}
</p>
))}
<Points points={p.points} tone={tone} />
<Link action={p.action} className="-mb-2 mt-2" />
</div>
</div>
</aside>
);
}
function AccentBar(p: CalloutProps & { t: Texts }) {
const tone = p.tone ?? 'info';
const k = TONE[tone];
const e = useEntrance();
return (
<aside ref={e.ref} aria-label={p.t[tone]} className={cn('relative py-1 pl-6', e.cls)}>
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px] rounded-full', BAR[tone])} />
<p className={cn('flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.08em]', k.ink)}>
<Icon name={p.icon ?? k.icon} size={15} className={e.icon} />
{p.t[tone]}
</p>
<p className="mt-2 text-[16px] font-semibold leading-snug text-foreground">{p.title}</p>
{paras(p.text).map((t, i) => (
<p key={i} className="mt-1.5 text-pretty text-[15.5px] leading-relaxed text-muted-foreground">
{t}
</p>
))}
<Points points={p.points} tone={tone} />
<Link action={p.action} className="-mb-2 mt-1" />
</aside>
);
}
function Collapsible(p: CalloutProps & { t: Texts }) {
const tone = p.tone ?? 'info';
const k = TONE[tone];
const [open, setOpen] = useState(!!p.defaultOpen);
const id = useId();
const e = useEntrance();
return (
<aside ref={e.ref} aria-label={p.t[tone]} className={cn('overflow-hidden rounded-xl border bg-card shadow-[var(--highlight),var(--shadow-sm)]', k.line, e.cls)}>
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((o) => !o)}
className={cn('group flex min-h-14 w-full cursor-pointer items-center gap-4 px-5 py-3 text-left transition-colors outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring', open ? k.surface : 'hover:bg-surface')}
>
<span className={cn('flex size-8 shrink-0 items-center justify-center rounded-lg', k.tile, k.text)}>
<Icon name={p.icon ?? k.icon} size={17} className={e.icon} />
</span>
<span className="min-w-0 flex-1">
<span className={cn('block text-[12px] font-medium', k.ink)}>{p.t[tone]}</span>
<span className="block text-[15px] font-semibold leading-snug text-foreground">{p.title}</span>
</span>
<span className="hidden text-[13px] text-muted-foreground sm:block">{open ? p.t.less : p.t.more}</span>
<span className={cn('flex size-8 shrink-0 items-center justify-center rounded-full border border-border bg-background transition-transform duration-300 ease-[var(--ease-kit)]', open && 'rotate-45')}>
<Icon name="plus" size={15} />
</span>
</button>
<div id={id} className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className={cn('min-h-0 overflow-hidden', !open && 'invisible')}>
<div className={cn('border-t px-5 pb-5 pt-4 sm:pl-[4.25rem]', k.line)}>
{paras(p.text).map((t, i) => (
<p key={i} className="mt-2 text-pretty text-[15px] leading-relaxed text-muted-foreground first:mt-0">
{t}
</p>
))}
<Points points={p.points} tone={tone} />
<Link action={p.action} className="-mb-2 mt-2" />
</div>
</div>
</div>
</aside>
);
}
function Featured(p: CalloutProps & { t: Texts }) {
const tone = p.tone ?? 'tip';
const k = TONE[tone];
const e = useEntrance();
return (
<aside ref={e.ref} aria-label={p.t[tone]} className={cn('relative overflow-hidden rounded-2xl border p-6 shadow-[var(--highlight),var(--shadow-md)] sm:p-8', k.surface, k.line, e.cls)}>
<div aria-hidden data-clip-ok className={cn('pointer-events-none absolute -right-16 -top-16 size-56 rounded-full opacity-60 blur-3xl', k.tile)} />
<div className="relative grid grid-cols-[minmax(0,1fr)] gap-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:gap-7">
<span className={cn('flex size-14 items-center justify-center rounded-2xl border bg-card shadow-[var(--shadow-sm)]', k.line, k.text)}>
<Icon name={p.icon ?? k.icon} size={26} strokeWidth={1.5} className={e.icon} />
</span>
<div>
<p className={cn('text-[13px] font-medium', k.ink)}>{p.t[tone]}</p>
<p className="mt-1 text-balance text-[clamp(1.25rem,1.1rem+0.5vw,1.5rem)] font-semibold leading-tight tracking-[-0.02em] text-foreground">{p.title}</p>
{paras(p.text).map((t, i) => (
<p key={i} className="mt-3 max-w-[38rem] text-pretty text-[15.5px] leading-relaxed text-[color-mix(in_oklab,var(--foreground)_78%,var(--background))]">
{t}
</p>
))}
{p.points?.length ? (
<ol className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2">
{p.points.map((pt, i) => (
<li key={pt} className="flex gap-3 rounded-xl border border-border bg-card/80 p-4 text-[14.5px] leading-snug text-foreground">
<span className={cn('flex size-6 shrink-0 items-center justify-center rounded-full text-[12px] font-semibold tabular-nums', k.tile, k.ink)}>{i + 1}</span>
<span className="pt-0.5">{pt}</span>
</li>
))}
</ol>
) : null}
<Link action={p.action} className="-mb-2 mt-4" />
</div>
</div>
</aside>
);
}
const PROSE = 'text-pretty text-[17px] leading-[1.75] text-[color-mix(in_oklab,var(--foreground)_86%,var(--background))]';
/**
* A note in a text: information, a tip, a warning or a success, as a soft
* panel, a side line, a note that opens or a large panel with steps.
* <Callout variant="soft" tone="warning" title="…" text="…" />
*/
export function Callout(props: CalloutProps) {
const { variant = 'soft', className, labels, context } = props;
const t = useTexts(TEXTS, labels);
const note = variant === 'accent-bar' ? <AccentBar {...props} t={t} /> : variant === 'collapsible' ? <Collapsible {...props} t={t} /> : variant === 'featured' ? <Featured {...props} t={t} /> : <Soft {...props} t={t} />;
if (!context) return <div className={className}>{note}</div>;
return (
<Section spacing="sm" className={className}>
<div className={cn('mx-auto', variant === 'featured' ? 'max-w-[48rem]' : 'max-w-[38rem]')}>
{context.before?.map((b, i) => (
<p key={i} className={cn(PROSE, 'mb-6', variant === 'featured' && 'mx-auto max-w-[38rem]')}>
{b}
</p>
))}
<div className="my-9">{note}</div>
{context.after?.map((b, i) => (
<p key={i} className={cn(PROSE, 'mt-6', variant === 'featured' && 'mx-auto max-w-[38rem]')}>
{b}
</p>
))}
</div>
</Section>
);
}Frequent questions
How do I install Callout?
With aiw add @ai-website/callout. The command puts components/ui/callout.tsx into your project, the code is yours from then on. It brings along icon and section. It needs no further npm packages.
Which variants does Callout have?
4 variants, Soft panel, Side line, Foldable note and Featured with steps. You pick one with the variant prop, for example variant="accent-bar".
Can I translate the texts of Callout?
Yes. Callout carries 6 texts of its own in English and German, such as “Information”, “Tip” and “Warning”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Callout?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Callout with aiw_components and install it with aiw_add @ai-website/callout. Name the variant in your prompt, for example variant="soft", and the assistant writes your content straight into the props.