This page documents the interactive components available in the core theme. All components are CORE-OWNED, consume --st-* semantic tokens, and use vanilla TypeScript for interactivity with no external CDN dependencies.
Who this is for
- Implementors placing accordions, tabs, tooltips, or media embeds in page content
- Developers extending interactive component behavior or building new vanilla JS components
Accordion
File: src/core/components/ui/Accordion.astro
File: src/core/components/ui/AccordionItem.astro
Single-expand accordion driven by shared vanilla JS state in the <Accordion> parent. Panel expand/collapse is animated via CSS grid-template-rows transitions.
Props — Accordion
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
variant | "default" | "faq" | "details" | no | "default" | ”faq” renders <dl> wrapper; “details” renders native <details>/<summary> |
defaultOpen | number | no | — | 1-based id of the item to open on load |
triggerSize | string | no | — | Visual font size for all triggers. Flows via CSS variable |
class | string | no | — | Additional CSS classes |
Props — AccordionItem
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
variant | "default" | "faq" | "details" | no | "default" | Must match parent Accordion variant |
id | number | yes | — | Unique integer within the accordion (1-based) |
title | string | yes | — | Panel trigger label |
headingLevel | 1–6 | no | 3 | Heading wrapping the trigger. Ignored in faq mode |
triggerSize | string | no | — | Per-item override of Accordion-level triggerSize |
Usage
---
import Accordion from "@core/components/ui/Accordion.astro";
import AccordionItem from "@core/components/ui/AccordionItem.astro";
---
<Accordion defaultOpen={1}>
<AccordionItem id={1} title="First item">
<p>Panel content here.</p>
</AccordionItem>
<AccordionItem id={2} title="Second item">
<p>Another panel.</p>
</AccordionItem>
</Accordion>
Behavior
- Only one panel is open at a time. Clicking an open panel’s trigger closes it.
defaultOpensets the initially open item by itsidprop (1-based integer).- The chevron icon rotates 180° when expanded.
- Panel open/close is animated via CSS
grid-template-rowstransitions. - DOM IDs are generated with
crypto.randomUUID()— safe to use multiple accordions on one page.
Accessibility
- Trigger is a
<button type="button">wrapped in a heading (h3by default). aria-expandedon the trigger reflects the open/closed state.aria-controlson the trigger references the panel element.- Panel has
role="region"andaria-labelledbypointing to the trigger.
FAQ Variant
Set variant="faq" on both <Accordion> and each <AccordionItem> to render FAQ-appropriate semantic HTML using definition lists (<dl>, <dt>, <dd>) instead of generic <div> and heading wrappers.
Element mapping:
| Role | default | faq |
|---|---|---|
| Container | <div> | <dl> |
| Trigger wrapper | <h1>–<h6> | <dt> |
| Panel wrapper | <div> | <dd> |
When variant="faq", the headingLevel prop is ignored — <dt> replaces the heading element. The panel omits role="region" since <dd> is already semantically associated with its <dt>.
---
import Accordion from "@core/components/ui/Accordion.astro";
import AccordionItem from "@core/components/ui/AccordionItem.astro";
---
<Accordion variant="faq">
<AccordionItem variant="faq" id={1} title="What services do you offer?">
<p>We offer web design, development, and SEO services.</p>
</AccordionItem>
<AccordionItem variant="faq" id={2} title="How long does a project take?">
<p>Most projects are completed within 4–8 weeks.</p>
</AccordionItem>
</Accordion>
FAQPage JSON-LD structured data is handled separately at the page level.
Trigger Size
The triggerSize prop controls the visual font size of trigger text independently from the semantic heading level. Set it on <Accordion> to apply to all items; set it on <AccordionItem> to override a single item.
Size scale: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" (same as the Heading primitive).
Cascade: per-item triggerSize > Accordion triggerSize > inherited from wrapper element (heading size in default mode, body text in faq mode).
<Accordion triggerSize="sm">
<AccordionItem id={1} title="Small trigger">
<p>All items use sm.</p>
</AccordionItem>
<AccordionItem id={2} title="Large override" triggerSize="2xl">
<p>This item overrides to 2xl.</p>
</AccordionItem>
</Accordion>
Details Variant
Set variant="details" on both <Accordion> and each <AccordionItem> to render native <details>/<summary> elements. This variant uses zero JavaScript — the browser handles open/close natively.
Key differences from default/FAQ variants:
| Behavior | default / FAQ | details |
|---|---|---|
| Open/close | JS-driven single-expand | Native <details> (multi-open allowed) |
| Animation | CSS grid-template-rows transition | Snap open/close (native <details>) |
| JavaScript | Vanilla JS handler | Zero JS |
defaultOpen | Accordion-level (1-based id) | Per-item via <details open> attribute |
Behavior: Multiple panels can be open simultaneously — this is expected native <details> behavior and an approved divergence from the single-expand behavior of default and FAQ variants.
---
import Accordion from "@core/components/ui/Accordion.astro";
import AccordionItem from "@core/components/ui/AccordionItem.astro";
---
<Accordion variant="details">
<AccordionItem variant="details" id={1} title="What is this?">
<p>Panel content using native details/summary.</p>
</AccordionItem>
<AccordionItem variant="details" id={2} title="How does it work?" defaultOpen>
<p>This panel starts open via the native open attribute.</p>
</AccordionItem>
</Accordion>
Tabs
File: src/core/components/ui/Tabs.astro
File: src/core/components/ui/TabPanel.astro
Tabs powered by a vanilla TypeScript controller. The controller handles all ARIA roles, keyboard navigation, and panel visibility automatically. Tab buttons are generated from the tabs string array; panel content goes in <TabPanel> children in matching order.
Props — Tabs
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
tabs | string[] | yes | — | Ordered tab labels. Must match <TabPanel> count |
orientation | "horizontal" | "vertical" | no | "horizontal" | Layout and aria-orientation value |
class | string | no | — | Additional CSS classes |
Props — TabPanel
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
class | string | no | — | Additional CSS classes |
Usage
---
import Tabs from "@core/components/ui/Tabs.astro";
import TabPanel from "@core/components/ui/TabPanel.astro";
---
<Tabs tabs={["Overview", "API"]} orientation="horizontal">
<TabPanel><p>Overview content.</p></TabPanel>
<TabPanel><p>API reference.</p></TabPanel>
</Tabs>
Behavior
- First tab is selected automatically by the vanilla JS controller.
- Horizontal tabs use the “Minimal” underline style (bottom-border indicator on the active tab).
- Vertical tabs use a sidebar highlight style (active tab receives a soft background).
- Keyboard navigation: Left/Right arrows for horizontal, Up/Down for vertical; Home/End jump to first/last. Focus wraps at boundaries.
- Bottom-border indicator for horizontal tabs uses scoped CSS (plain
border-bottom) to avoid Tailwind v4--tw-border-stylevariable issues.
Accessibility
- All ARIA attributes (
role="tablist",role="tab",role="tabpanel",aria-selected,aria-controls,aria-labelledby,aria-orientation,tabindex) are managed by the vanilla JS controller. - Base
outline: noneon tab buttons prevents browser default focus outlines from programmatic.focus()calls;:focus-visiblescoped CSS restores a keyboard-only focus ring using project focus tokens.
Tooltip
File: src/core/components/ui/Tooltip.astro
Hover/focus tooltip using vanilla JS visibility toggling with CSS absolute positioning. Does not use the CSS popover API (which would promote the element to the top layer and break position: absolute).
Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
content | string | yes | — | Tooltip text |
placement | "top" | "bottom" | "start" | "end" | no | "top" | CSS placement side |
Usage
---
import Tooltip from "@core/components/ui/Tooltip.astro";
import Button from "@core/components/primitives/Button.astro";
---
<Tooltip content="Saves your work" placement="top">
<Button variant="secondary">Save</Button>
</Tooltip>
Behavior
- Shows on mouseenter/focusin; hides on mouseleave/focusout or Escape key.
- Opacity transition via CSS (
duration-fast). aria-describedbyis set on slotted children at initialization time pointing to the tooltip span.- Limitation: does not auto-flip at viewport edges. Use
placementto position away from edges manually.
Accessibility
- Tooltip span has
role="tooltip"and a stableid(UUID-based). aria-describedbywired from trigger to tooltip at runtime.- Escape key dismissal via a
keydownevent listener onwindow.
CopyButton
File: src/core/components/ui/CopyButton.astro
Clipboard copy button with 2-second success feedback using the native Clipboard API.
Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
text | string | yes | — | Text to copy to clipboard on click |
label | string | no | "Copy" | Button label in the default (pre-copy) state |
class | string | no | — | Additional CSS classes |
Usage
---
import CopyButton from "@core/components/ui/CopyButton.astro";
---
<CopyButton text="npm install some-package" label="Copy install command" />
Behavior
- On click: copies
textto clipboard, shows “Copied ✓” for 2 seconds, then reverts. .catch()handler silently ignores failures (e.g., non-HTTPS context, iframe restrictions).aria-labelupdates to “Copied!” while in the success state.
Code block auto-enhancement
The src/scripts/copy-code-blocks.ts script runs sitewide and automatically injects a copy button into every pre > code block at DOMContentLoaded. No per-block markup is needed. The injected button uses the same CSS class pattern as CopyButton for visual consistency.
PostShare
File: src/core/components/ui/PostShare.astro
Web Share API button with clipboard copy fallback for article sharing (PLN-6, REQ-00184). On supported browsers, opens the native OS share sheet. On unsupported browsers (Firefox desktop, older browsers), copies the article URL to clipboard with 2-second visual confirmation.
Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
url | string | yes | — | Canonical URL to share |
title | string | yes | — | Article title for the Web Share API payload |
text | string | yes | — | Article description for the Web Share API text param |
class | string | no | — | Additional CSS classes |
Usage
---
import PostShare from "@core/components/ui/PostShare.astro";
---
<PostShare
url={Astro.url.href}
title={entry.data.title}
text={entry.data.description}
/>
Behavior
- On click (Web Share API supported): opens the native OS share sheet with the article URL, title, and description. If the user cancels the share sheet (
AbortError), the button silently returns to its default state. - On click (Web Share API unsupported): copies the article URL to clipboard, swaps the button text/icon to “Link copied” with a checkmark for ~2 seconds, then reverts.
.catch()on clipboard failure silently ignores errors (matching the CopyButton pattern).- The button is hidden by default until vanilla JS initializes — the control is not present in server-rendered HTML (zero JS footprint).
Site configuration
Controlled via webSharing in site config (see Site Configuration):
| Field | Type | Default | Notes |
|---|---|---|---|
webSharing.enabled | boolean | true | Enable/disable sitewide |
Per-article opt-out
Add webSharing: false to article frontmatter to disable the share button on that article:
---
title: My Article
webSharing: false
---
Config precedence: frontmatter → site config → schema default (true).
Browser coverage
The Web Share API is supported on most mobile browsers and Chrome/Edge/Safari desktop. Firefox desktop and some older browsers receive the clipboard copy fallback. This is the accepted degradation path.
Accessibility
- Dynamic
aria-label: “Share this article” in default state, “Link copied” during feedback state. - Keyboard-operable: standard
<button>element, activates with Enter or Space. - Visible focus ring via the shared focus token utilities.
- Hidden by default to prevent FOUC before JS initializes.
TocAside / TocInline — sticky sidebar + collapsible inline
See Table of Contents for the full three-component reference.
- TocAside — sticky sidebar card with
position: sticky, internal scroll container, andIntersectionObserveractive-link highlighting. The topmost visible heading’s TOC link receivestoc-list__link--activestyling. SetactiveLinks={false}to disable. - TocInline — collapsible in-page TOC with vanilla JS toggle. Starts expanded, no scroll or active links. Used in docs route main content header.
ScrollToTop
File: src/core/components/ui/ScrollToTop.astro
Fixed-position back-to-top button. Appears after scrolling 300px. Respects prefers-reduced-motion.
Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
position | "start" | "end" | no | siteConfig.scrollToTopPosition | Horizontal position (start=left, end=right) |
Site configuration
Controlled via two SiteConfig fields (see Site Configuration):
| Field | Type | Default | Notes |
|---|---|---|---|
scrollToTop | boolean | true | Enable/disable sitewide |
scrollToTopPosition | "start" | "end" | "end" | Default horizontal position |
Per-page opt-out
Add hideScrollToTop: true to any article or docs frontmatter to suppress the button on that page:
---
title: My Page
hideScrollToTop: true
---
Behavior
- Initial visibility is set from
window.scrollYon initialization (handles deep links and browser-restored scroll positions). - Scroll listener updates visibility as the user scrolls.
- Click: smooth scroll to top, or instant scroll when
prefers-reduced-motion: reduceis active. - Fade-in/out via CSS opacity transition (200ms).
Accessibility
aria-label="Back to top"on the button.- Receives a visible focus ring via the shared focus token utilities.
- Hidden by default to prevent FOUC (Flash of Unstyled Content) before JS initializes.
YouTubeEmbed
File: src/core/components/ui/YouTubeEmbed.astro
Click-to-load YouTube video embed with a facade pattern. Zero third-party requests at page load — thumbnails are fetched at build time and served locally (REQ-00180, REQ-00104).
Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
id | string | yes | — | Bare video ID or full YouTube URL (watch, youtu.be, embed formats) |
title | string | yes | — | Descriptive title for iframe accessibility |
eager | boolean | no | false | Bypass the facade — render the iframe directly with loading="eager" |
aspect | string | no | "16/9" | CSS aspect ratio string |
class | string | no | — | Additional CSS classes |
Usage
---
import YouTubeEmbed from "@core/components/ui/YouTubeEmbed.astro";
---
{/* Facade mode (default) — bare video ID */}
<YouTubeEmbed id="dQw4w9WgXcQ" title="Rick Astley — Never Gonna Give You Up" />
{/* Facade mode — full YouTube URL */}
<YouTubeEmbed
id="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
title="Rick Astley — Never Gonna Give You Up"
/>
{/* Eager mode — no facade, immediate iframe */}
<YouTubeEmbed id="dQw4w9WgXcQ" title="Rick Astley" eager />
{/* Custom aspect ratio */}
<YouTubeEmbed id="dQw4w9WgXcQ" title="Rick Astley (4:3)" aspect="4/3" />
Behavior
- Facade mode (default): Renders a locally-served thumbnail with a play icon overlay. Clicking loads the YouTube iframe. No third-party requests until interaction.
- Eager mode: Renders the YouTube iframe directly with
loading="eager". No facade, no thumbnail fetch. - No-JS fallback: The facade is an
<a>link to the YouTube video page withrole="button". Without JavaScript, clicking navigates to YouTube. - Build-time thumbnails: Fetched from
img.youtube.comat build time, cached in.astro/cache/youtube-thumbnails/, and served from/assets/youtube/. If YouTube is unreachable, a generic placeholder is used. - URL parsing: Accepts bare 11-character video IDs and common YouTube URL formats. Unrecognized formats produce a build-time error.
Accessibility
- The facade
<a>hasrole="button"andaria-label="Play: {title}". - Both Enter and Space activate the facade (per WAI-ARIA button pattern).
- After the facade-to-iframe transition, focus moves to the iframe (
tabindex="-1") with a wrapper fallback. - The iframe carries a descriptive
titleattribute. - Canonical focus ring pattern on the facade.
Privacy
Thumbnails are served from local build assets — no requests to YouTube, Google, or any third-party domain at page load. The YouTube iframe loads only after explicit user interaction (click or keyboard activation). This satisfies REQ-00104 (GDPR compliance).
The YouTube iframe uses referrerpolicy="strict-origin-when-cross-origin" (not no-referrer) because YouTube requires a referrer to validate embed authorization. This sends only the site origin, not the full page path.
BEM Hooks
.youtube-embed, .youtube-embed__facade, .youtube-embed__play-icon, .youtube-embed__iframe
Embed
File: src/core/components/ui/Embed.astro
Generic responsive iframe wrapper for arbitrary embeds. Privacy-safe by default with restrictive sandbox and referrerpolicy. Escape-hatch props available for embeds that need more capability.
Props
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
src | string | yes | — | URL of the content to embed |
title | string | yes | — | Descriptive title for iframe accessibility |
aspect | string | no | "16/9" | CSS aspect ratio string |
allowFullscreen | boolean | no | false | Add allowfullscreen attribute |
sandbox | string | no | "allow-scripts allow-same-origin" | Override default sandbox restrictions |
allow | string | no | — | Permissions Policy allow attribute |
referrerPolicy | string | no | "no-referrer" | Override default referrer policy |
class | string | no | — | Additional CSS classes |
Usage
---
import Embed from "@core/components/ui/Embed.astro";
---
{/* Basic embed with safe defaults */}
<Embed
src="https://www.openstreetmap.org/export/embed.html?..."
title="Map of Central London"
/>
{/* With escape hatches */}
<Embed
src="https://example.com/player"
title="Video player"
allowFullscreen
sandbox="allow-scripts allow-same-origin allow-popups"
allow="fullscreen; autoplay"
/>
Security Defaults
The component applies restrictive iframe attributes by default:
sandbox="allow-scripts allow-same-origin"— blocks popups, form submission, top-level navigationreferrerpolicy="no-referrer"— prevents sending the page URL to the third-party origin- No
allowattribute — no camera, microphone, geolocation, etc. - No
allowfullscreen— fullscreen is opt-in
Override any default via the corresponding prop when the embed provider requires it.
Accessibility
- The iframe carries a descriptive
titleattribute sourced from the requiredtitleprop. loading="lazy"defers iframe loading until the element approaches the viewport.
BEM Hooks
.embed, .embed__iframe