Callout
Feedback

Callout

Docs-grade note/tip/warning/danger callout with an icon chip, tinted hairline rail, markdown-friendly body styles, and an optional grid-rows collapse.

Install

npx shadcn@latest add @paragon/callout

callout.tsx

"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>
  );
}