Callout
SourceA static, in-flow message box for status, guidance, or feedback — an icon, a title, body text, and optional actions or a dismiss control. Its look is two orthogonal axes: variant sets the emphasis and tone sets the colour, and the tone is carried by the icon, the tint, and a visually-hidden label, never by colour alone.
Basic
A Callout.Root holds a leading Callout.Icon and a Callout.Content column with a Callout.Title and Callout.Description. The default is an outline info box; the icon and tone label come from the tone automatically.
Heads up
This is a callout — a static, in-flow message for guidance, status, or feedback.
"use client";
import { Callout } from "@stridge/noctis/callout";
export default function CalloutBasic() {
return (
<Callout.Root>
<Callout.Icon />
<Callout.Content>
<Callout.Title>Heads up</Callout.Title>
<Callout.Description>
This is a callout — a static, in-flow message for guidance, status, or feedback.
</Callout.Description>
</Callout.Content>
</Callout.Root>
);
}
Variants
variant is the emphasis axis: a transparent outline with a coloured edge (the default), a quiet soft tint, or a filled solid. It is orthogonal to tone — every variant works with every tone. Reach for outline as the everyday default, soft when you want a filled tint that reads a touch louder, and solid only for a message that must dominate. The tone rides the saturated icon, edge, and fill; the title and body stay neutral so they read at full contrast on every tone.
The soft variant
Emphasis is orthogonal to tone — every variant works with every tone.
The outline variant
Emphasis is orthogonal to tone — every variant works with every tone.
The solid variant
Emphasis is orthogonal to tone — every variant works with every tone.
"use client";
import { Callout } from "@stridge/noctis/callout";
const VARIANTS: Callout.Variant[] = ["soft", "outline", "solid"];
export default function CalloutVariants() {
return (
<div className="flex flex-col gap-3">
{VARIANTS.map((variant) => (
<Callout.Root key={variant} variant={variant} tone="info">
<Callout.Icon />
<Callout.Content>
<Callout.Title>The {variant} variant</Callout.Title>
<Callout.Description>
Emphasis is orthogonal to tone — every variant works with every tone.
</Callout.Description>
</Callout.Content>
</Callout.Root>
))}
</div>
);
}
Tones
tone sets the colour identity — the five message tones: info (the default) for guidance, success for confirmation, warning for caution, danger for errors, and neutral for an un-toned note. Each paints through per-tone colour proxies that default to its status-role family, so a retheme propagates for free (and you can retune a single tone — see Design tokens). Each tone also drives the default Callout.Icon glyph plus a visually-hidden tone label (e.g. "Warning:") so the meaning reaches assistive tech.
Note
An un-toned note for plain, non-status guidance.
Good to know
Informational guidance the reader may find useful.
Saved
Your changes were saved successfully.
Trial ending
Your trial ends in three days.
Payment failed
We couldn't process your last payment.
"use client";
import { Callout } from "@stridge/noctis/callout";
const TONES: { tone: Callout.Tone; title: string; body: string }[] = [
{ tone: "neutral", title: "Note", body: "An un-toned note for plain, non-status guidance." },
{ tone: "info", title: "Good to know", body: "Informational guidance the reader may find useful." },
{ tone: "success", title: "Saved", body: "Your changes were saved successfully." },
{ tone: "warning", title: "Trial ending", body: "Your trial ends in three days." },
{ tone: "danger", title: "Payment failed", body: "We couldn't process your last payment." },
];
export default function CalloutTones() {
return (
<div className="flex flex-col gap-3">
{TONES.map(({ tone, title, body }) => (
<Callout.Root key={tone} tone={tone}>
<Callout.Icon />
<Callout.Content>
<Callout.Title>{title}</Callout.Title>
<Callout.Description>{body}</Callout.Description>
</Callout.Content>
</Callout.Root>
))}
</div>
);
}
Without a title
Most callouts are a single line — a leading icon and a short message, no Callout.Title. Drop the title and the icon centres on the description's first line; the box stays a compact band. Works across every variant.
Your changes have been saved.
Your free trial ends in 3 days.
We couldn't reach the server — retrying.
"use client";
import { Callout } from "@stridge/noctis/callout";
// Most callouts are a single line — an icon and a short message, no title. Shown across all three
// variants (each in a fitting tone) in that common form.
const ROWS: { variant: Callout.Variant; tone: Callout.Tone; message: string }[] = [
{ variant: "soft", tone: "success", message: "Your changes have been saved." },
{ variant: "outline", tone: "warning", message: "Your free trial ends in 3 days." },
{ variant: "solid", tone: "danger", message: "We couldn't reach the server — retrying." },
];
export default function CalloutTitleLess() {
return (
<div className="flex flex-col gap-3">
{ROWS.map(({ variant, tone, message }) => (
<Callout.Root key={variant} variant={variant} tone={tone}>
<Callout.Icon />
<Callout.Content>
<Callout.Description>{message}</Callout.Description>
</Callout.Content>
</Callout.Root>
))}
</div>
);
}
Without an icon
Omit Callout.Icon for a clean, text-only box — the message sits flush to the leading edge. Available on every variant and tone (switch tones below). Note the tone then reads from colour alone, so when the severity must reach assistive tech keep a Callout.Icon (it carries a visually-hidden tone label) or state it in the copy.
A text-only soft callout — no icon, just the message.
A text-only outline callout — no icon, just the message.
A text-only solid callout — no icon, just the message.
"use client";
import { Callout } from "@stridge/noctis/callout";
import { Tabs } from "@stridge/noctis/tabs";
// Omit Callout.Icon for a clean, text-only box — the message sits flush to the edge. Tabbed by tone so
// every tone × variant is browsable; without the icon the tone reads from colour alone, so lean on a
// title or explicit copy when the severity must reach assistive tech.
const TONES: { value: Callout.Tone; label: string }[] = [
{ value: "info", label: "Info" },
{ value: "success", label: "Success" },
{ value: "warning", label: "Warning" },
{ value: "danger", label: "Danger" },
{ value: "neutral", label: "Neutral" },
];
const VARIANTS: Callout.Variant[] = ["soft", "outline", "solid"];
export default function CalloutIconLess() {
return (
<Tabs.Root defaultValue="info">
<Tabs.List aria-label="Callout tone">
{TONES.map((tone) => (
<Tabs.Tab key={tone.value} value={tone.value}>
{tone.label}
</Tabs.Tab>
))}
<Tabs.Indicator />
</Tabs.List>
{TONES.map((tone) => (
<Tabs.Panel key={tone.value} value={tone.value}>
<div className="flex flex-col gap-3 pt-3">
{VARIANTS.map((variant) => (
<Callout.Root key={variant} variant={variant} tone={tone.value}>
<Callout.Content>
<Callout.Description>
A text-only {variant} callout — no icon, just the message.
</Callout.Description>
</Callout.Content>
</Callout.Root>
))}
</div>
</Tabs.Panel>
))}
</Tabs.Root>
);
}
Icon alignment
The leading icon centres on the first text line, not the box as a whole: with no title it rides the first line of the description (and multi-line copy flows beneath it rather than pushing the icon to the middle); with a title it rides the title's line, the description flowing below.
With no title, the icon badge stays level with the first line of the message — even when the copy is long enough to wrap onto a second line it never drifts to the middle of the block.
Titled callouts are unchanged
When a title is present the badge rides the title's first line and the description flows beneath it, exactly as before.
"use client";
import { Callout } from "@stridge/noctis/callout";
export default function CalloutAlignment() {
return (
<div className="flex flex-col gap-3">
<Callout.Root tone="warning">
<Callout.Icon />
<Callout.Content>
<Callout.Description>
With no title, the icon badge stays level with the first line of the message — even when the copy is long
enough to wrap onto a second line it never drifts to the middle of the block.
</Callout.Description>
</Callout.Content>
</Callout.Root>
<Callout.Root tone="info">
<Callout.Icon />
<Callout.Content>
<Callout.Title>Titled callouts are unchanged</Callout.Title>
<Callout.Description>
When a title is present the badge rides the title's first line and the description flows beneath it,
exactly as before.
</Callout.Description>
</Callout.Content>
</Callout.Root>
</div>
);
}
Actions
Compose a Callout.Actions row inside the content for follow-up buttons or links. Keep them low-emphasis — a secondary or ghost Button — so they support the message instead of competing with it. The row wraps on narrow widths.
Trial ending soon
Your trial ends in three days. Upgrade now to keep your projects and data.
"use client";
import { Button } from "@stridge/noctis/button";
import { Callout } from "@stridge/noctis/callout";
export default function CalloutWithActions() {
return (
<Callout.Root tone="warning">
<Callout.Icon />
<Callout.Content>
<Callout.Title>Trial ending soon</Callout.Title>
<Callout.Description>
Your trial ends in three days. Upgrade now to keep your projects and data.
</Callout.Description>
<Callout.Actions>
<Button size="sm" variant="secondary">
Upgrade
</Button>
<Button size="sm" variant="ghost">
Remind me later
</Button>
</Callout.Actions>
</Callout.Content>
</Callout.Root>
);
}
Dismissible
Rendering a Callout.Close makes the box dismissible — an icon-only ghost button in the corner. It works uncontrolled (internal state) or controlled via open/onOpenChange (shown here, so the box can be brought back). Don't make a callout carrying critical or required information dismissible.
New workspace settings
You can now manage members and billing from one place.
"use client";
import { Button } from "@stridge/noctis/button";
import { Callout } from "@stridge/noctis/callout";
import { useRef, useState } from "react";
export default function CalloutDismissible() {
const [open, setOpen] = useState(true);
const triggerRef = useRef<HTMLButtonElement>(null);
// Dismissing unmounts the focused close button, so move focus back to the trigger — done right in the
// dismiss event handler (the trigger is always mounted), no Effect needed. See
// https://react.dev/learn/you-might-not-need-an-effect.
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) triggerRef.current?.focus();
};
return (
<div className="flex flex-col items-start gap-3">
{open ? (
<Callout.Root tone="info" open={open} onOpenChange={handleOpenChange}>
<Callout.Icon />
<Callout.Content>
<Callout.Title>New workspace settings</Callout.Title>
<Callout.Description>You can now manage members and billing from one place.</Callout.Description>
</Callout.Content>
<Callout.Close />
</Callout.Root>
) : null}
<Button ref={triggerRef} size="sm" variant="secondary" onClick={() => setOpen(true)}>
Show callout
</Button>
</div>
);
}
Sizes
size spans xs, sm, md (the default), and lg. The type, the icon badge, and the padding all scale with it — reach for xs/sm for dense, inline notices and lg for a prominent, page-level message.
The xs size
The type, icon, and padding scale with the size.
The sm size
The type, icon, and padding scale with the size.
The md size
The type, icon, and padding scale with the size.
The lg size
The type, icon, and padding scale with the size.
"use client";
import { Callout } from "@stridge/noctis/callout";
const SIZES: Callout.Size[] = ["xs", "sm", "md", "lg"];
export default function CalloutSizes() {
return (
<div className="flex flex-col gap-3">
{SIZES.map((size) => (
<Callout.Root key={size} size={size} tone="success">
<Callout.Icon />
<Callout.Content>
<Callout.Title>The {size} size</Callout.Title>
<Callout.Description>The type, icon, and padding scale with the size.</Callout.Description>
</Callout.Content>
</Callout.Root>
))}
</div>
);
}
Custom icon
Callout.Icon frames the tone's default glyph in a bordered circular badge; pass children to swap the glyph for your own (it still sits inside the badge). Wrap an <Icon> (or any inline svg) inside it — don't put data-slot on the icon itself, which would drop its own token sizing. The visually-hidden tone label is kept regardless, so the severity is still announced.
Introducing AI summaries
Pass children to Callout.Icon to swap the tone’s default glyph for your own.
"use client";
import { Icon } from "@stridge/noctis";
import { Callout } from "@stridge/noctis/callout";
import { Sparkles } from "lucide-react";
export default function CalloutCustomIcon() {
return (
<Callout.Root tone="info" variant="outline">
<Callout.Icon>
<Icon icon={Sparkles} />
</Callout.Icon>
<Callout.Content>
<Callout.Title>Introducing AI summaries</Callout.Title>
<Callout.Description>
Pass children to <code>Callout.Icon</code> to swap the tone’s default glyph for your own.
</Callout.Description>
</Callout.Content>
</Callout.Root>
);
}
Announcing dynamic messages
A callout present on page load is static — it carries no live role by default (live="off"), which is correct: a live region present on load is neither announced by screen readers nor appropriate. For a message inserted after load — a "Saved" confirmation, a failed-request error — set live="polite" (renders role="status") for non-urgent feedback or live="assertive" (role="alert") for urgent, time-sensitive content, so assistive tech announces it.
"use client";
import { Button } from "@stridge/noctis/button";
import { Callout } from "@stridge/noctis/callout";
import { useState } from "react";
export default function CalloutLiveRegion() {
const [saved, setSaved] = useState(false);
return (
<div className="flex flex-col items-start gap-3">
<Button size="sm" variant="secondary" onClick={() => setSaved(true)} disabled={saved}>
Save changes
</Button>
{saved ? (
// Inserted after load, so it opts into announcement with `live="polite"` (role="status").
<Callout.Root tone="success" live="polite" open={saved} onOpenChange={setSaved}>
<Callout.Icon />
<Callout.Content>
<Callout.Description>Your changes were saved.</Callout.Description>
</Callout.Content>
<Callout.Close />
</Callout.Root>
) : null}
</div>
);
}
Accessibility
- Tone is never colour-only. The drawn
Callout.Iconglyph is decorative (aria-hidden), but the part also renders a visually-hidden tone label ("Warning:", "Error:", …) so the severity reaches assistive tech, satisfying WCAG 1.4.1 (Use of Color). If you omit the icon, convey the tone in the title text. - Live role by arrival, not appearance. Keep
live="off"(the default) for static, on-load content; opt intorole="status"/role="alert"only for dynamically-inserted messages. Never hardcode an alert role on a callout that's present at load — it won't be announced and is over-assertive. - Dismiss is labelled and focus-safe.
Callout.Closecarries a localized "Dismiss" accessible name (a passedaria-labelwins). After it removes the box, move focus to a sensible nearby location in your app. - Don't make required content dismissible. A callout the user must act on (a blocking error, a required step) should not render a
Callout.Close.
Anatomy
Compose the box from its parts. Callout.Root renders a <div>; spread Callout.Root.props(...) onto an <aside> (or any element) to style it as a callout.
Callout.Root— the box; ownsvariant(emphasis),tone(colour),size, theliveannouncement politeness, and theopen/onOpenChangedismiss model.Callout.Icon— the leading status icon. Defaults to the tone's glyph (overridable) and carries the visually-hidden tone label.Callout.Content— the stacked column that wraps the title, description, and actions.Callout.Title— the headline.Callout.Description— the body text.Callout.Actions— the trailing button/link row.Callout.Close— the optional dismiss control (a ghostButton); rendering it makes the box dismissible.
Every rendered part carries a data-slot (noctis-callout on the box, noctis-callout-icon, noctis-callout-content, and so on) for host-side styling — pair it with the data-variant/data-tone/data-size axes the root stamps, off which the colour grid and per-size metrics are keyed.
Theme scope
ThemeScope re-tunes a whole region's shape — corner radius, density, and type scale — through the cascade, with no per-component prop and without touching the app-wide theme. Tweak the knobs below and watch the Callout retune. See Customization for nesting, reset, and the portal caveat.
Scoped to its region
Radius, density, and type scale retune through the cascade — no per-component prop.
"use client";
import { DENSITY_PRESETS, FONT_SCALE_PRESETS, RADIUS_PRESETS, ThemeScope } from "@stridge/noctis";
import { Callout } from "@stridge/noctis/callout";
import { Select } from "@stridge/noctis/select";
import { useState } from "react";
/** Title-case a preset key for its select label. */
const label = (key: string) => key.charAt(0).toUpperCase() + key.slice(1);
/** One labelled preset picker — its options are a shipped `ThemeScope` preset map, so the playground's
* vocab is exactly what the primitive accepts. */
function Knob({
name,
presets,
value,
onChange,
}: {
name: string;
presets: Record<string, number>;
value: string;
onChange: (value: string) => void;
}) {
const keys = Object.keys(presets);
return (
<label className="flex flex-col gap-1.5">
<span className="text-mini font-medium text-subtle">{name}</span>
<Select.Root
items={Object.fromEntries(keys.map((key) => [key, label(key)]))}
value={value}
onValueChange={(next) => onChange(String(next))}
>
<Select.Trigger aria-label={name} className="w-40">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
{keys.map((key) => (
<Select.Item key={key} value={key}>
{label(key)}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
</label>
);
}
/**
* A live `ThemeScope` playground: the three seed knobs (radius, density, type scale) drive a scope
* wrapping a callout, so it re-tunes together — corners, spacing, and text size — with no per-component
* prop and without touching the app-wide theme.
*/
export default function CalloutThemeScope() {
const [radius, setRadius] = useState<keyof typeof RADIUS_PRESETS>("pill");
const [density, setDensity] = useState<keyof typeof DENSITY_PRESETS>("default");
const [fontScale, setFontScale] = useState<keyof typeof FONT_SCALE_PRESETS>("default");
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap gap-3">
<Knob name="Radius" presets={RADIUS_PRESETS} value={radius} onChange={(v) => setRadius(v as typeof radius)} />
<Knob
name="Density"
presets={DENSITY_PRESETS}
value={density}
onChange={(v) => setDensity(v as typeof density)}
/>
<Knob
name="Font scale"
presets={FONT_SCALE_PRESETS}
value={fontScale}
onChange={(v) => setFontScale(v as typeof fontScale)}
/>
</div>
<div className="rounded-md border border-dashed border-border p-4">
<ThemeScope radius={radius} density={density} fontScale={fontScale}>
<Callout.Root tone="info">
<Callout.Icon />
<Callout.Content>
<Callout.Title>Scoped to its region</Callout.Title>
<Callout.Description>
Radius, density, and type scale retune through the cascade — no per-component prop.
</Callout.Description>
</Callout.Content>
</Callout.Root>
</ThemeScope>
</div>
</div>
);
}
On surfaces
The same box re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Trial ending soon
Your trial ends in three days. Upgrade to keep your data.
Trial ending soon
Your trial ends in three days. Upgrade to keep your data.
Trial ending soon
Your trial ends in three days. Upgrade to keep your data.
Trial ending soon
Your trial ends in three days. Upgrade to keep your data.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every callout in that region retunes — e.g. .notices { --noctis-callout-border-radius: var(--noctis-radius-sm); } squares the boxes beneath it.
Colour is minted too — every part is a proxy that defaults to a status role and re-points per tone, so you can retune the palette without touching the shared roles. Override one globally, or scope it to a single tone:
/* every callout: warmer soft fill + heavier outline edge */
.notices {
--noctis-callout-background-color: var(--noctis-color-info-faint);
--noctis-callout-border-color: var(--noctis-color-border-strong);
}
/* only the success tone, only in this region */
.notices [data-tone="success"] {
--noctis-callout-icon-color: var(--noctis-color-success-hover);
}The title and body default to neutral roles (--noctis-callout-title-color, --noctis-callout-description-color) for legibility — tint them here if your brand calls for it. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table. Expand a row for the full type and description.