← Beiträge

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.

Per E-Mail kommentieren
← Beiträge