A Callout component for Astro — info boxes with socials and band members
A reusable Astro callout: info / note / warning, optional title, social links, and a members section with name, role, and multiple platforms per person.
While writing the last few posts I kept needing the same block: a coloured box with an icon, an optional title, and a few social links to whatever band or project I was talking about. Classic case for a small MDX component — and the longer I tinkered, the more it grew into.
What it can do:
- three visual variants —
info,note,warning, - optional title,
- inline Markdown in the body via
<slot />, - up to five social icons (YouTube, TikTok, Facebook, Instagram, Website),
- a dedicated members section with name, role, and any number of links per person.
In the GDPR post about embedding YouTube I use it to link the band right under the video — and below that, the singer with her own set of channels.
The component
src/components/Callout.astro — frontmatter:
---
type SocialKey = 'youtube' | 'tiktok' | 'facebook' | 'instagram' | 'website';
interface MemberLink { social: SocialKey; href: string }
interface Member { name: string; role?: string; links: MemberLink[] }
interface Props {
type?: 'info' | 'note' | 'warning';
title?: string;
youtube?: string;
tiktok?: string;
facebook?: string;
instagram?: string;
website?: string;
membersTitle?: string;
members?: Member[];
}
const {
type = 'info', title,
youtube, tiktok, facebook, instagram, website,
membersTitle, members = [],
} = Astro.props;
---
The SVG icons (info / note / warning + social icons) sit as string constants in the same file and get rendered with set:html. Inline SVG is much nicer here than pulling in another icon package — saves a build step and a few kilobytes.
Render core:
<aside class={`callout callout-${type}`}>
<span class="callout-icon" set:html={icons[type]} />
<div class="callout-body">
{title && <p class="callout-title">{title}</p>}
<slot />
{socials.length > 0 && (
<ul class="callout-socials">
{socials.map((s) => (
<li><a href={s.href} target="_blank" rel="noopener noreferrer"
aria-label={s.label} title={s.label}
set:html={socialIcons[s.key]} /></li>
))}
</ul>
)}
{members.length > 0 && (
<div class="callout-members">
{membersTitle && <p class="callout-members-title">{membersTitle}</p>}
<ul>
{members.map((m) => (
<li>
<div class="callout-member-text">
<span class="callout-member-name">{m.name}</span>
{m.role && <span class="callout-member-role">{m.role}</span>}
</div>
{m.links.length > 0 && (
<ul class="callout-member-links">
{m.links.map((l) => (
<li><a href={l.href} target="_blank" rel="noopener noreferrer"
title={socialLabels[l.social]}
set:html={socialIcons[l.social]} /></li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
)}
</div>
</aside>
Three details that mattered to me:
<slot />before the lists — body text stays in the reading flow, socials and members sit underneath. Reads sensibly even with JS off and unrendered icons.rel="noopener noreferrer"on every external link — guards againstwindow.openerhijacking and avoids accidentally weakening my site-wideReferrer-Policy: no-referrerfor outbound profile paths.aria-label+title— the icons have no visible label, so screen readers and hover tooltips get a readable name.
Usage
Minimal
import Callout from '~/components/Callout.astro';
<Callout>Quick note without a title.</Callout>
With title and type
<Callout type="warning" title="Heads up">
Run `astro check` before deploying.
</Callout>
With social links
The five social props (youtube, tiktok, facebook, instagram, website) appear as an icon row under the body. Order is fixed, empty values are skipped.
<Callout
type="info"
title="My project"
website="https://example.com"
youtube="https://www.youtube.com/@example"
>
Where the interesting stuff happens.
</Callout>
With members
members takes an array of { name, role?, links } — each member gets its own mini-card with its own links. membersTitle is optional and renders as a section heading above.
<Callout
type="info"
title="OZONE Band is from Phuket, Thailand"
tiktok="https://www.tiktok.com/@ozonebandfc"
youtube="https://www.youtube.com/@OZONEBandFC"
facebook="https://www.facebook.com/OZONEBandFC"
instagram="https://www.instagram.com/ozonebandfc/"
membersTitle="Band members"
members={[{
name: "Nene Royal (aka Praew)",
role: "Lead vocals & guitar",
links: [
{ social: "website", href: "https://neneroyal.com" },
{ social: "youtube", href: "https://www.youtube.com/@neneroyalmusic" },
{ social: "tiktok", href: "https://www.tiktok.com/@neneroyalmusic" },
{ social: "instagram", href: "https://www.instagram.com/neneroyalmusic/" },
{ social: "facebook", href: "https://www.facebook.com/NeneRoyalMusic" },
],
}]}
>
Since 2021, regularly do busking show at Naka Market, every SAT 19:30 hrs.
</Callout>
Live:
Multiple members work the same way — just add more objects to the array. Each gets its own block with name, role, and icon row.
MDX gotcha: JSX expressions on one line
My first attempt formatted the members array across multiple lines:
members={[
{ name: "…", links: [{ social: "tiktok", href: "…" }] }
]}
@astrojs/mdx parses that with acorn via micromark-util-events-to-acorn — and the combination of a multi-line JSX expression with nested object literals reliably threw Could not parse expression with acorn. Fix: put the array on a single line. Ugly but stable:
members={[{ name: "…", links: [{ social: "tiktok", href: "…" }] }]}
If the line gets too long, lift the array into the frontmatter of an .astro wrapper and import the component. For one-off posts the inline form is fine.
Styling decisions
- Border-left + matching accent background: classic callout look that holds up across many themes and colour schemes.
- Members section with a dashed
border-top: visual separation of “band links” vs. “person links” without a second background. - Icons as inline SVG with
currentColor: inherits text colour, hover flips to accent + white. No images, no webfont latency. - Roles small and dimmed (
opacity: 0.8,0.78rem): the hierarchy should read name → role → icons, not all at the same volume.
Wrap-up
The component lands at roughly 270 lines — props, inline SVGs, render logic, and scoped styles in a single file. No external icon package, no slot stack, no React. Once it’s wired up, <Callout> is as unobtrusive in MDX as a Markdown blockquote — but with a lot more affordance when I actually need it.
If you want to add more platforms (Mastodon, Bluesky, …), each is two lines: an SVG string in socialIcons, plus an entry in the SocialKey union and the default props. The architecture is intentionally flat — no plugin system, no registry, just a few ifs.