Callout-Komponente für Astro — Hinweisboxen mit Socials und Bandmitgliedern
Eine wiederverwendbare Astro-Hinweisbox: Info / Note / Warning, optionaler Titel, Social-Links und eine Mitglieder-Sektion mit Name, Rolle und mehreren Plattformen pro Person.
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 — gegenwindow.opener-Hijacking und um meine seitenweiteReferrer-Policy: no-referrernicht 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>
Mit Social-Links
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.