A Callout component for Astro — info boxes with socials and band members


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 against window.opener hijacking and avoids accidentally weakening my site-wide Referrer-Policy: no-referrer for 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>

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.