Docs-grade note/tip/warning/danger callout with an icon chip, tinted hairline rail, markdown-friendly body styles, and an optional grid-rows collapse.
npx shadcn@latest add @paragon/callout"use client";
import * as React from "react";
import {
ChevronDown,
Info,
Lightbulb,
OctagonAlert,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
type CalloutVariant = "info" | "tip" | "warning" | "danger";
const variantIcon: Record<
CalloutVariant,
React.ComponentType<{ className?: string }>
> = {
info: Info,
tip: Lightbulb,
warning: TriangleAlert,
danger: OctagonAlert,
};
const variantChip: Record<CalloutVariant, string> = {
info: "bg-blue-600/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400",
tip: "bg-success/10 text-success",
warning: "bg-warning/15 text-warning",
danger: "bg-destructive/10 text-destructive",
};
const variantRail: Record<CalloutVariant, string> = {
info: "bg-blue-600/50 dark:bg-blue-400/50",
tip: "bg-success/50",
warning: "bg-warning/60",
danger: "bg-destructive/50",
};
const defaultTitle: Record<CalloutVariant, string> = {
info: "Note",
tip: "Tip",
warning: "Warning",
danger: "Danger",
};
export interface CalloutProps
extends Omit<React.ComponentProps<"div">, "title"> {
variant?: CalloutVariant;
title?: React.ReactNode;
/** Allow the body to collapse behind the title row. */
collapsible?: boolean;
/** Initial state when collapsible. */
defaultOpen?: boolean;
}
/**
* Docs-grade callout: icon chip, tinted hairline left rail, and
* markdown-friendly body typography (inline code, links, and lists are
* styled for free). The collapsible variant folds via grid-template-rows —
* the sanctioned height animation — with the chevron and body fading in
* concert. Semantics stay plain prose; nothing here is a live region.
*/
export function Callout({
variant = "info",
title,
collapsible = false,
defaultOpen = true,
className,
children,
...props
}: CalloutProps) {
const [open, setOpen] = React.useState(defaultOpen);
const bodyId = React.useId();
const Icon = variantIcon[variant];
const expanded = collapsible ? open : true;
const heading = (
<>
<span
aria-hidden
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-md",
variantChip[variant],
)}
>
<Icon className="size-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium text-foreground">
{title ?? defaultTitle[variant]}
</span>
</>
);
return (
<div
data-slot="callout"
data-variant={variant}
className={cn(
"relative overflow-hidden rounded-xl bg-card py-3 pr-4 pl-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
{/* Tinted hairline rail, inset so the concentric radius stays clean. */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-y-2 left-2 w-0.5 rounded-full",
variantRail[variant],
)}
/>
{collapsible ? (
<button
type="button"
aria-expanded={open}
aria-controls={bodyId}
onClick={() => setOpen((current) => !current)}
className="group relative -my-1 flex w-full items-center gap-2.5 rounded-md py-1 outline-none after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
{heading}
<ChevronDown
aria-hidden
className={cn(
"size-4 shrink-0 text-muted-foreground transition-[rotate,color] duration-200 ease-[var(--ease-out)] group-hover:text-foreground",
open && "-rotate-180",
)}
/>
</button>
) : (
<div className="flex items-center gap-2.5">{heading}</div>
)}
<div
id={bodyId}
className={cn(
"grid transition-[grid-template-rows,opacity] duration-200 ease-[var(--ease-out)] motion-reduce:transition-[opacity]",
expanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
>
<div
className="overflow-hidden"
// Keep collapsed content out of the tab order and the a11y tree.
inert={expanded ? undefined : true}
>
<div
className={cn(
"pt-2 pl-[34px] text-[13px] leading-relaxed text-muted-foreground",
// Markdown-friendly body: style the elements prose renderers emit.
"[&_strong]:font-medium [&_strong]:text-foreground",
"[&_a]:font-medium [&_a]:text-foreground [&_a]:underline [&_a]:underline-offset-2 [&_a:hover]:decoration-2",
"[&_code]:rounded [&_code]:bg-secondary [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_code]:text-secondary-foreground",
"[&_ul]:mt-1.5 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-4 [&_ol]:mt-1.5 [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-4",
"[&_p+p]:mt-1.5",
)}
>
{children}
</div>
</div>
</div>
</div>
);
}