Callout-Komponente für Astro — Hinweisboxen mit Socials und Bandmitgliedern


Beim Schreiben der letzten Posts habe ich immer wieder den gleichen Block gebraucht: ein farbiger Kasten mit Icon, optional Titel, optional ein paar Social-Links zu der Band oder dem Projekt, von dem ich gerade erzählt habe. Klassischer Fall für eine kleine MDX-Komponente — und je länger ich daran rumgemacht habe, desto mehr ist daraus geworden.

Am Ende kann die Komponente:

  • drei visuelle Varianten — info, note, warning,
  • optionaler Titel,
  • inline-Markdown im Body über <slot />,
  • bis zu fünf Social-Icons (YouTube, TikTok, Facebook, Instagram, Website),
  • eine eigene Mitglieder-Sektion mit Name, Rolle und beliebig vielen Links pro Person.

Im DSGVO-Post zur YouTube-Einbettung nutze ich sie, um direkt unter dem Video die Band zu verlinken — und darunter die Sängerin mit eigenem Kanal-Set.

Die Komponente

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;
---

Die SVG-Icons (Info / Note / Warning + Social-Icons) liegen als String-Konstanten im selben File und werden mit set:html ausgegeben. Inline-SVG ist hier deutlich angenehmer als ein zusätzliches Icon-Paket — das spart einen Build-Schritt und ein paar Kilobyte.

Render-Kern:

<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>

Drei Details, die mir wichtig waren:

  • <slot /> vor den Listen — der Body-Text bleibt im Lesefluss, Socials und Mitglieder rutschen darunter. So liest sich’s auch dann sinnvoll, wenn JS aus ist und keine Icons rendern.
  • rel="noopener noreferrer" auf jedem externen Link — gegen window.opener-Hijacking und um meine seitenweite Referrer-Policy: no-referrer nicht versehentlich auf Profil-Pfade aufzuweichen.
  • aria-label + title — die Icons haben kein sichtbares Label, also bekommen Screenreader und Hover-Tooltip einen lesbaren Namen.

Verwendung

Minimal

import Callout from '~/components/Callout.astro';

<Callout>Kurzer Hinweis ohne Titel.</Callout>

Mit Titel und Typ

<Callout type="warning" title="Achtung">
  Vor dem Deploy `astro check` laufen lassen.
</Callout>

Die fünf Social-Props (youtube, tiktok, facebook, instagram, website) erscheinen als Icon-Reihe unter dem Body. Reihenfolge ist fest, leere Werte werden ignoriert.

<Callout
  type="info"
  title="Mein Projekt"
  website="https://example.com"
  youtube="https://www.youtube.com/@example"
>
  Hier passiert das Spannende.
</Callout>

Mit Mitgliedern

members nimmt ein Array { name, role?, links } — jedes Mitglied bekommt eine eigene Mini-Karte mit eigenen Links. membersTitle ist optional und steht als Sektions-Überschrift darüber.

<Callout
  type="info"
  title="OZONE Band von 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="Bandmitglieder"
  members={[{
    name: "Nene Royal",
    role: "Sängerin & Gitarristin",
    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" },
    ],
  }]}
>
  Seit 2021 regelmäßig beim Busking-Auftritt auf dem Naka Market, jeden Samstag ab 19:30 Uhr.
</Callout>

So sieht das live aus:

Mehrere Mitglieder funktionieren genauso — einfach weitere Objekte in das Array. Jedes Mitglied bekommt seinen eigenen Block mit Name, Rolle und Icon-Reihe.

MDX-Stolperfalle: JSX-Expressions auf einer Zeile

Beim ersten Versuch hatte ich das members-Array mehrzeilig formatiert:

members={[
  { name: "…", links: [{ social: "tiktok", href: "…" }] }
]}

@astrojs/mdx parst das mit acorn über micromark-util-events-to-acorn — und die Kombination aus mehrzeiligem JSX-Expression mit verschachtelten Objektliteralen hat reproduzierbar einen Could not parse expression with acorn-Fehler geworfen. Lösung: das Array auf eine Zeile schreiben. Hässlich, aber stabil:

members={[{ name: "…", links: [{ social: "tiktok", href: "…" }] }]}

Wer die Zeile zu lang findet, kann das Array in das Frontmatter eines .astro-Wrappers ziehen und die Komponente importieren. Für Einzelposts ist die Inline-Variante okay.

Styling-Entscheidungen

  • Border-Left + matchender Akzent-Background: standardklassischer Callout-Look, der bei vielen Themes und Farben funktioniert.
  • Mitglieder-Sektion mit gestrichelter border-top: visuelle Trennung von „Band-Links” und „Personen-Links” ohne zweiten Hintergrund.
  • Icons als Inline-SVG mit currentColor: erbt die Textfarbe, hover wird zu Akzentfarbe + weiß. Keine Bilder, keine Webfont-Latenz.
  • Rollen klein und gedimmt (opacity: 0.8, 0.78rem): die Hierarchie soll Name → Rolle → Icons sein, nicht alle gleich laut.

Fazit

Die Komponente liegt bei rund 270 Zeilen — Props-Definition, Inline-SVGs, Render-Logik und Scoped Styles in einer Datei. Kein externes Icon-Paket, kein Slot-Stack, kein React. Einmal eingebunden ist <Callout> in MDX so unauffällig wie ein Markdown-Quote — aber mit deutlich mehr Funktion, wenn ich sie mal brauche.

Wenn du weitere Plattformen brauchst (Mastodon, Bluesky, …), sind das jeweils zwei Zeilen: SVG-String in socialIcons, Eintrag im SocialKey-Union, Default-Prop am Anfang. Die Architektur ist absichtlich flach gehalten — kein Plugin-System, kein Registry, einfach nur ein paar ifs.