docs / core / callout

Callout

a note that sits in the flow and stays there. a toast leaves on its own and an Announcement links somewhere. this one waits to be read.

intensity · recedesrsc · client

provenance

authored in usva

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

an error summary at the top of a form, or a warning on a docs pageone action on the end, when there is something to do about itnot for a transient confirmation. that is a toastdanger for anything you would not interrupt a screen reader fornever stack four in a row. that is a page with no hierarchy

a11y

role="status" on the calm tones, so a change is announced politely · role="alert" only on danger, because alert cuts a screen reader off mid sentence · the tone icon is aria-hidden, so tone is never the only carrierjest-axe
live demo · try it out

A note

Nothing is wrong. This is just context that has to stay on the page.
customize
toneneutral says nothing, danger interrupts
titleoptional. the body can stand alone
bodywhat the reader has to know
actionone button on the end of the block
dismissibleadds a close button. off by default
usagecopy
import { Callout } from "usva/primitives/callout";

<Callout title="A note">
  Nothing is wrong. This is just context that has to stay on the page.
</Callout>

props

proptypedefaultnotes
tone"neutral" | "info" | "success" | "warning" | "danger""neutral"picks the tint, the icon and the dot. neutral gets none of the three.
titleReactNodeoptional heading above the body. the body can stand alone.
actionReactNodeone control on the end of the block. more than one belongs on the page.
iconReactNode | falseswaps the tone icon, or false drops it.
dismissiblebooleanfalseadds a close button. off by default, because a callout that leaves is a toast.
onDismiss() => voidfires after the close button removes the callout.
dismissLabelstring"Dismiss"accessible name on the close button.
role"status" | "alert"status on the calm tones, alert on danger. pass it to override.

get it

npx shadcn add https://usva.build/r/callout.jsoncopy
source · components/ui/callout.tsxexactly what this command copiescopy
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";

export type CalloutTone = "neutral" | "info" | "success" | "warning" | "danger";

interface ToneStyle {
  icon: React.ReactNode;
  text: string;
  dot: string;
  tint: string;
}

const toneStyles: Record<Exclude<CalloutTone, "neutral">, ToneStyle> = {
  info: {
    icon: <InfoIcon />,
    text: "text-info",
    dot: "bg-info",
    tint: "bg-info/[0.06]",
  },
  success: {
    icon: <CheckIcon />,
    text: "text-success",
    dot: "bg-success",
    tint: "bg-success/[0.06]",
  },
  warning: {
    icon: <AlertIcon />,
    text: "text-warning",
    dot: "bg-warning",
    tint: "bg-warning/[0.06]",
  },
  danger: {
    icon: <ErrorIcon />,
    text: "text-danger",
    dot: "bg-danger",
    tint: "bg-danger/[0.07]",
  },
};

export interface CalloutProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
  tone?: CalloutTone;
  title?: React.ReactNode;
  /** One action on the end of the block. Anything more belongs on the page. */
  action?: React.ReactNode;
  /** Swap the tone icon, or pass false to drop it. */
  icon?: React.ReactNode | false;
  /** A callout that vanishes on its own is a toast, so this is opt-in. */
  dismissible?: boolean;
  onDismiss?: () => void;
  dismissLabel?: string;
}

export const Callout = React.forwardRef<HTMLDivElement, CalloutProps>(
  (
    {
      className,
      tone = "neutral",
      title,
      action,
      icon,
      dismissible = false,
      onDismiss,
      dismissLabel = "Dismiss",
      children,
      ...props
    },
    ref,
  ) => {
    const [dismissed, setDismissed] = React.useState(false);
    if (dismissed) return null;

    const styles = tone === "neutral" ? undefined : toneStyles[tone];
    const glyph = icon === false ? null : (icon ?? styles?.icon ?? null);

    return (
      <div
        ref={ref}
        role={tone === "danger" ? "alert" : "status"}
        data-tone={tone}
        className={cn(
          "relative isolate flex w-full gap-3 rounded-xl border border-border bg-surface p-4 text-ink",
          className,
        )}
        {...props}
      >
        {styles ? (
          <span
            aria-hidden="true"
            className={cn(
              "pointer-events-none absolute inset-0 rounded-[inherit]",
              styles.tint,
            )}
          />
        ) : null}

        {glyph ? (
          <span
            aria-hidden="true"
            className={cn("relative mt-px shrink-0", styles?.text)}
          >
            {glyph}
          </span>
        ) : null}

        <div className="relative flex min-w-0 flex-1 flex-col gap-1">
          {title ? (
            <div className="flex items-center gap-2">
              {styles ? (
                <span
                  aria-hidden="true"
                  className={cn("size-1.5 shrink-0 rounded-full", styles.dot)}
                />
              ) : null}
              <p className="font-semibold text-sm text-ink">{title}</p>
            </div>
          ) : null}
          {children ? (
            <div className="text-sm text-muted text-pretty">{children}</div>
          ) : null}
          {action ? <div className="mt-2 flex gap-2">{action}</div> : null}
        </div>

        {dismissible ? (
          <button
            type="button"
            aria-label={dismissLabel}
            onClick={() => {
              setDismissed(true);
              onDismiss?.();
            }}
            className={cn(
              "relative -mt-1 -mr-1 grid size-7 shrink-0 place-items-center self-start rounded-md text-muted outline-none",
              "transition-control duration-base ease-soft motion-reduce:transition-none",
              "hover:bg-surface-2 hover:text-ink focus-visible:ring-focus",
            )}
          >
            <CloseIcon />
          </button>
        ) : null}
      </div>
    );
  },
);
Callout.displayName = "Callout";

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width="18"
      height="18"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="m8.5 12 2.5 2.5 4.5-5" />
    </svg>
  );
}

function AlertIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width="18"
      height="18"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
      <path d="M12 9v4M12 17h.01" />
    </svg>
  );
}

function ErrorIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width="18"
      height="18"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="m15 9-6 6M9 9l6 6" />
    </svg>
  );
}

function InfoIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width="18"
      height="18"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="M12 11v5M12 8h.01" />
    </svg>
  );
}

function CloseIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width="14"
      height="14"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>
  );
}