Callout
A banner shrunk to prose scale: a toned left rule and a wash, for an aside inside a card.
Server-compatible: no hooks, no client boundary. Reach for it inside a card or a docs page; reach for Banner when the notice belongs to the whole page. Every tone ships a shaped icon so the meaning survives without color. In dark mode the muted tokens are already translucent, so the extra wash comes off. The HTML title attribute is replaced by the title prop.
Install
$
npx shadcn@latest add @vibra/calloutNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Callout } from "@/components/ui/callout"
export default function CalloutDemo() {
return (
<Callout className="w-full max-w-md" variant="info" title="Rates are cached">
Exchange rates refresh once an hour, so a figure converted to EUR can trail the source by up to
60 minutes.
</Callout>
)
}Tones
Every tone, plus the icon turned off so the rule carries it alone.
import { Callout } from "@/components/ui/callout"
export default function CalloutVariants() {
return (
<div className="flex w-full max-w-md flex-col gap-2.5">
<Callout variant="default">Sits inside prose without taking the page over.</Callout>
<Callout variant="info" title="Rates are cached">
Conversions can trail the source by up to 60 minutes.
</Callout>
<Callout variant="success" title="Verified">
This metric reconciles with the finance export to the cent.
</Callout>
<Callout variant="warning" title="Estimated">
Today's figure is partial until the 23:50 UTC close.
</Callout>
<Callout variant="danger" title="Deprecated">
The v1 events table stops receiving writes on 1 December.
</Callout>
<Callout variant="neutral" icon={null}>
With the icon off, the left rule carries the tone on its own.
</Callout>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "neutral" | "success" | "warning" | "danger" | "info" | "default" | Sets the left rule, the wash, and the default icon; neutral is an alias of default. |
| title | React.ReactNode | — | An optional first line in medium weight. |
| children | React.ReactNode | — | The body of the aside. |
| icon | React.ReactNode | null | the tone's icon | Replaces the tone's default icon; pass null for no icon at all. |
Dependencies
Registry
npm
Source
import * as React from "react"
import { CircleCheckIcon, CircleXIcon, InfoIcon, TriangleAlertIcon } from "lucide-react"
import { cn } from "@/lib/utils"
export type CalloutVariant = "default" | "neutral" | "success" | "warning" | "danger" | "info"
// The tone lives in a 2px left rule and a wash behind it. In dark mode the
// -muted tokens are already translucent, so the extra /50 comes off.
const CALLOUT_VARIANTS: Record<CalloutVariant, string> = {
default: "border-border bg-muted/50 dark:bg-muted",
// Same as default; both spellings exist so a tone prop can be passed through verbatim.
neutral: "border-border bg-muted/50 dark:bg-muted",
success: "border-success bg-success-muted",
warning: "border-warning bg-warning-muted",
danger: "border-danger bg-danger-muted",
info: "border-info bg-info-muted",
}
const CALLOUT_ICON_TONES: Record<CalloutVariant, string> = {
default: "text-muted-foreground",
neutral: "text-muted-foreground",
success: "text-success",
warning: "text-warning",
danger: "text-danger",
info: "text-info",
}
// Shape carries the tone as well as color does: a check never means the same as a cross.
const CALLOUT_ICONS: Record<CalloutVariant, React.ComponentType<React.ComponentProps<"svg">>> = {
default: InfoIcon,
neutral: InfoIcon,
success: CircleCheckIcon,
warning: TriangleAlertIcon,
danger: CircleXIcon,
info: InfoIcon,
}
// `title` is content here, not the HTML tooltip attribute, so it replaces it.
export type CalloutProps = Omit<React.ComponentProps<"div">, "title"> & {
variant?: CalloutVariant
title?: React.ReactNode
/** Replaces the tone's default icon; pass null for no icon at all. */
icon?: React.ReactNode | null
}
/** A <Banner> shrunk to prose scale: an aside inside a card or a docs page, not a page-level notice. */
function Callout({ className, variant = "default", title, icon, children, ...props }: CalloutProps) {
const DefaultIcon = CALLOUT_ICONS[variant]
const resolvedIcon = icon === undefined ? <DefaultIcon /> : icon
return (
<div
data-slot="callout"
data-variant={variant}
className={cn(
"flex items-start gap-2 rounded-e-md border-s-2 px-3 py-2 text-sm text-foreground",
CALLOUT_VARIANTS[variant],
className
)}
{...props}
>
{resolvedIcon ? (
<span
data-slot="callout-icon"
aria-hidden="true"
className={cn(
"mt-0.5 shrink-0 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
CALLOUT_ICON_TONES[variant]
)}
>
{resolvedIcon}
</span>
) : null}
<div data-slot="callout-content" className="flex min-w-0 flex-1 flex-col gap-0.5">
{title ? (
<div data-slot="callout-title" className="font-medium">
{title}
</div>
) : null}
{children ? (
<div data-slot="callout-description" className="text-pretty">
{children}
</div>
) : null}
</div>
</div>
)
}
export { Callout }