
Typo3 Frontend Patterns
- 8 installs
- 1 repo stars
- Updated July 11, 2026
- netresearch/typo3-frontend-patterns-skill
Helps with frontend development tasks.
About
typo3-frontend-patterns is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted coding.
- typo3-frontend-patterns
- Frontend Development
- AI-coding skill
Typo3 Frontend Patterns by the numbers
- 8 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,733 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/typo3-frontend-patterns-skill --skill typo3-frontend-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 11, 2026 |
| Repository | netresearch/typo3-frontend-patterns-skill ↗ |
What it does
Helps with frontend development tasks.
Files
TYPO3 Frontend Patterns
Reusable implementation patterns for TYPO3 v13 and v14.3 LTS sitepackage development. Each pattern provides a complete implementation with Fluid template, TypeScript plugin, SCSS partial, and accessibility considerations.
v14 heads-up: the core no longer concatenates or compresses frontend CSS/JS (Breaking #108055). Pair these patterns with an external build tool — see typo3-vite-skill. Fluid 5 (v14) enforces strict ViewHelper typing (#108148): all VHs in pattern templates must have typed arguments + render(): string. Camino (v14.1+, #108539) is the v14 core's default theme alternative to bootstrap-package — patterns stay theme-agnostic.These patterns solve common frontend problems that every TYPO3 sitepackage encounters. Instead of building from scratch, use these proven implementations that handle edge cases (scroll performance, reduced-motion, keyboard navigation, ARIA live regions) correctly from the start.
Available Patterns
| Pattern | Description | Key Features |
|---|---|---|
| Sticky Header | Scroll-triggered fixed header | IntersectionObserver, CSS transitions |
| Lazy Loading | Deferred component initialization | IntersectionObserver, placeholder content |
| Breadcrumb | Breadcrumb navigation | JSON-LD structured data, Schema.org |
| Language Switcher | Multi-language navigation | b13/menus LanguageMenu, flag icons |
| Animations | Scroll-triggered animations | prefers-reduced-motion support |
| Scroll to Anchor | Smooth scroll with offset | Header height compensation |
| Skeleton Loading | CSS placeholder animations | Content layout stability |
| Toast Notification | Notification messages | Auto-dismiss, ARIA live region |
| Back to Top | Scroll-to-top button | Visibility threshold, smooth scroll |
Implementation Convention
Each pattern follows the same structure: 1. Fluid template -- Markup with semantic HTML and ARIA attributes 2. TypeScript plugin -- Behavior with DOMContentLoaded initialization 3. SCSS partial -- Styling with Bootstrap integration 4. Vite entrypoint -- Code splitting via *.entry.ts
References
references/patterns-sticky-header.md-- Scroll-triggered fixed header with IntersectionObserverreferences/patterns-lazy-loading.md-- Deferred component initialization with placeholder contentreferences/patterns-animations.md-- Scroll animations with prefers-reduced-motion supportreferences/patterns-breadcrumb.md-- Breadcrumb navigation with JSON-LD structured datareferences/patterns-language-switcher.md-- Multi-language navigation with b13/menus LanguageMenureferences/patterns-scroll-to-anchor.md-- Smooth scroll with sticky header offset compensationreferences/patterns-skeleton-loading.md-- CSS placeholder animations for content loadingreferences/patterns-toast-notification.md-- Auto-dismiss notifications with ARIA live regionreferences/patterns-back-to-top.md-- Scroll-to-top button with visibility threshold
Pattern: Scroll Animations
Standard scroll-triggered animations with prefers-reduced-motion support and IntersectionObserver.
TypeScript
// TypeScript/Plugins/scroll-animations.ts
type AnimationType = 'fade-in' | 'slide-up' | 'slide-left' | 'slide-right' | 'scale-in';
interface AnimationOptions {
rootMargin?: string;
threshold?: number;
staggerDelay?: number; // ms between children animations
}
const DEFAULT_OPTIONS: AnimationOptions = {
rootMargin: '0px 0px -50px 0px', // Trigger 50px before fully visible
threshold: 0.1,
staggerDelay: 100,
};
/**
* Initialize scroll-triggered animations.
* Elements with data-animate="<type>" will animate when scrolled into view.
* Elements with data-animate-stagger will stagger their children.
*/
export function initScrollAnimations(options: AnimationOptions = {}): void {
// Respect reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// Show all elements immediately without animation
document.querySelectorAll<HTMLElement>('[data-animate]').forEach((el) => {
el.classList.add('is-visible');
});
return;
}
const opts = { ...DEFAULT_OPTIONS, ...options };
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const element = entry.target as HTMLElement;
if (element.hasAttribute('data-animate-stagger')) {
// Stagger children
const children = element.querySelectorAll<HTMLElement>('[data-animate]');
children.forEach((child, index) => {
setTimeout(() => {
child.classList.add('is-visible');
}, index * (opts.staggerDelay ?? 100));
});
} else {
element.classList.add('is-visible');
}
observer.unobserve(element);
}
});
},
{
rootMargin: opts.rootMargin,
threshold: opts.threshold,
},
);
// Observe individual animated elements
document.querySelectorAll<HTMLElement>('[data-animate]:not([data-animate-stagger] [data-animate])').forEach((el) => {
observer.observe(el);
});
// Observe stagger containers
document.querySelectorAll<HTMLElement>('[data-animate-stagger]').forEach((el) => {
observer.observe(el);
});
}SCSS
// Components/_animations.scss
// Base state: hidden before animation
[data-animate] {
opacity: 0;
transition: opacity 0.6s ease, transform 0.6s ease;
will-change: opacity, transform;
}
// Visible state (added by JS)
[data-animate].is-visible {
opacity: 1;
transform: none;
}
// Animation types
[data-animate='fade-in'] {
// Only opacity change, no transform
}
[data-animate='slide-up'] {
transform: translateY(2rem);
}
[data-animate='slide-left'] {
transform: translateX(2rem);
}
[data-animate='slide-right'] {
transform: translateX(-2rem);
}
[data-animate='scale-in'] {
transform: scale(0.95);
}
// Custom durations via data attribute
[data-animate-duration='fast'] {
transition-duration: 0.3s;
}
[data-animate-duration='slow'] {
transition-duration: 1s;
}
// Reduced motion: show everything immediately
@media (prefers-reduced-motion: reduce) {
[data-animate] {
opacity: 1;
transform: none;
transition: none;
}
}Fluid Usage
Single Element
<div class="ce-teaser" data-animate="fade-in">
<!-- Content fades in when scrolled into view -->
</div>Staggered Grid
<div class="row g-4" data-animate-stagger>
<f:for each="{items}" as="item">
<div class="col-md-4" data-animate="slide-up">
<!-- Each card slides up with 100ms delay between them -->
</div>
</f:for>
</div>Custom Duration
<h2 data-animate="fade-in" data-animate-duration="slow">
Slow fade in headline
</h2>Entrypoint Integration
Add to main.entry.ts (loaded on every page):
import { initScrollAnimations } from '../TypeScript/Plugins/scroll-animations';
document.addEventListener('DOMContentLoaded', () => {
initScrollAnimations();
});Playwright E2E Test
// tests/e2e/scroll-animations.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Scroll Animations', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('animates elements when scrolled into view', async ({ page }) => {
const firstAnimated = page.locator('[data-animate]').first();
await expect(firstAnimated).not.toHaveClass(/is-visible/);
await firstAnimated.scrollIntoViewIfNeeded();
await expect(firstAnimated).toHaveClass(/is-visible/);
});
test('respects reduced motion preference', async ({ browser }) => {
const context = await browser.newContext({
reducedMotion: 'reduce',
});
const page = await context.newPage();
await page.goto('/');
await expect(page.locator('[data-animate]').first()).toHaveClass(/is-visible/);
await context.close();
});
});Guidelines
1. Always respect `prefers-reduced-motion` -- animations are removed entirely 2. Use `will-change` sparingly -- only on elements that will animate 3. Keep animations subtle -- max 2rem translation, 0.6s duration 4. Don't animate above-the-fold content -- it should be immediately visible 5. Stagger delay max ~100ms -- longer feels sluggish 6. No animation on text content -- only on containers, cards, images
Pattern: Back to Top Button
Scroll-triggered button with smooth scroll, accessible markup, and animation.
TypeScript
// TypeScript/Plugins/backToTop.ts
const SCROLL_THRESHOLD = 400;
const BUTTON_CLASS = 'back-to-top';
const VISIBLE_CLASS = 'back-to-top--visible';
export function initBackToTop(): void {
const button = document.querySelector<HTMLButtonElement>(`.${BUTTON_CLASS}`);
if (!button) return;
let ticking = false;
const update = (): void => {
button.classList.toggle(VISIBLE_CLASS, window.scrollY > SCROLL_THRESHOLD);
ticking = false;
};
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(update);
ticking = true;
}
}, { passive: true });
button.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
// Move focus to skip links / top of page for accessibility
const skipLink = document.querySelector<HTMLElement>('.visually-hidden-focusable');
if (skipLink) {
skipLink.focus({ preventScroll: true });
}
});
// Initial check
update();
}Fluid Partial
<!-- PageView/Partials/BackToTop.html -->
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-uri-known-prefixed-attribute="true">
<button type="button"
class="back-to-top"
aria-label="{f:translate(key: 'backToTop', extensionName: 'my_sitepackage')}">
<f:image src="EXT:my_sitepackage/Resources/Public/Svg/chevron-up.svg" width="24" height="24" alt="" />
</button>
</html>Include in the page layout before </body>:
<!-- PageView/Layouts/Default.html -->
<footer class="main-footer">
<f:render section="Footer" />
</footer>
<f:render partial="BackToTop" />SCSS
// Components/_back-to-top.scss
.back-to-top {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border: none;
border-radius: 50%;
background-color: $primary;
color: $white;
cursor: pointer;
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.2);
transition: opacity 0.3s ease, transform 0.3s ease, background-color 0.2s ease;
// Hidden by default
opacity: 0;
transform: translateY(1rem);
pointer-events: none;
&--visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
&:hover {
background-color: darken($primary, 10%);
transform: translateY(-0.125rem);
}
&:focus-visible {
outline: 0.1875rem solid $primary;
outline-offset: 0.125rem;
}
// Hide in print
@media print {
display: none !important;
}
// Mobile: smaller and closer to edge
@media (max-width: map-get($grid-breakpoints, md) - 0.02px) {
bottom: 1rem;
right: 1rem;
width: 2.75rem;
height: 2.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.back-to-top {
transition: none;
}
}Entrypoint
Add to main.entry.ts:
import { initBackToTop } from '../TypeScript/Plugins/backToTop';
document.addEventListener('DOMContentLoaded', () => {
initBackToTop();
});Labels
<!-- locallang.xlf -->
<trans-unit id="backToTop"><source>Back to top</source></trans-unit>
<!-- de.locallang.xlf -->
<trans-unit id="backToTop">
<source>Back to top</source>
<target>Nach oben</target>
</trans-unit>Playwright E2E Test
// tests/e2e/back-to-top.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Back to Top', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('is hidden at top of page', async ({ page }) => {
await expect(page.locator('.back-to-top')).not.toHaveClass(/back-to-top--visible/);
});
test('appears after scrolling', async ({ page }) => {
await page.evaluate(() => window.scrollTo(0, 500));
await expect(page.locator('.back-to-top')).toHaveClass(/back-to-top--visible/);
});
test('scrolls to top on click', async ({ page }) => {
await page.evaluate(() => window.scrollTo(0, 500));
await page.locator('.back-to-top').click({ force: true });
await expect(page).toHaveURL(/.*/, { timeout: 5000 });
const scrollY = await page.evaluate(() => window.scrollY);
expect(scrollY).toBe(0);
});
});Pattern: Breadcrumb
Complete breadcrumb pattern with Fluid partial, JSON-LD structured data, and SCSS.
TypoScript DataProcessor
# Already configured in page.typoscript via PAGEVIEW
page.10.dataProcessing {
80 = menu
80 {
as = breadcrumb
special = rootline
special.range = 0|-1
}
}Fluid Partial
<!-- PageView/Partials/Header/Breadcrumb.html -->
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-uri-known-prefixed-attribute="true">
<f:if condition="{breadcrumb -> f:count()} > 1">
<nav aria-label="{f:translate(key: 'breadcrumb', extensionName: 'my_sitepackage')}" class="breadcrumb-nav">
<div class="container">
<ol class="breadcrumb" itemscope itemtype="https://schema.org/BreadcrumbList">
<f:for each="{breadcrumb}" as="item" iteration="iter">
<li class="breadcrumb-item{f:if(condition: '{iter.isLast}', then: ' active')}"
itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<f:if condition="{iter.isLast}">
<f:then>
<span itemprop="name">{item.title}</span>
</f:then>
<f:else>
<f:link.typolink parameter="{item.link}" itemprop="item">
<span itemprop="name">{item.title}</span>
</f:link.typolink>
</f:else>
</f:if>
<meta itemprop="position" content="{iter.cycle}" />
</li>
</f:for>
</ol>
</div>
</nav>
<!-- JSON-LD Structured Data for SEO -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
<f:for each="{breadcrumb}" as="item" iteration="iter">
{
"@type": "ListItem",
"position": {iter.cycle},
"name": "{item.title}",
"item": "{f:uri.typolink(parameter: item.link)}"
}<f:if condition="!{iter.isLast}">,</f:if>
</f:for>
]
}
</script>
</f:if>
</html>SCSS
// Page/_breadcrumb.scss
.breadcrumb-nav {
padding: 0.75rem 0;
background-color: $gray-100;
border-bottom: 1px solid $border-color;
}
.breadcrumb {
margin-bottom: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
font-size: 0.875rem;
.breadcrumb-item {
display: flex;
align-items: center;
+ .breadcrumb-item {
padding-left: 0.5rem;
&::before {
display: inline-block;
padding-right: 0.5rem;
content: '/';
color: $text-muted;
}
}
a {
color: $text-muted;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: $primary;
text-decoration: underline;
}
}
&.active {
color: $body-color;
font-weight: $font-weight-bold;
}
}
}
// Mobile: truncate long breadcrumbs
@media (max-width: map-get($grid-breakpoints, md) - 0.02px) {
.breadcrumb {
overflow-x: auto;
flex-wrap: nowrap;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.breadcrumb-item {
white-space: nowrap;
}
}
}Include in Page Layout
<!-- PageView/Layouts/Default.html -->
<header class="main-header" id="main-header">
<f:render section="Header" />
</header>
<f:render partial="Header/Breadcrumb" arguments="{breadcrumb: breadcrumb}" />
<main id="main-content">
<f:render section="Main" />
</main>Labels
<!-- locallang.xlf -->
<trans-unit id="breadcrumb"><source>Breadcrumb</source></trans-unit>
<!-- de.locallang.xlf -->
<trans-unit id="breadcrumb">
<source>Breadcrumb</source>
<target>Brotkrumennavigation</target>
</trans-unit>Key Points
- Schema.org markup in both Microdata (HTML) and JSON-LD (script) for maximum SEO compatibility
- Last item not linked -- current page is plain text with
activeclass - Mobile scrollable -- horizontal scroll instead of wrapping on small screens
- Accessible --
navwitharia-label, semanticollist - Hidden on root page -- only shown when breadcrumb has more than 1 item
Pattern: Language Switcher
Complete language switcher pattern using b13/menus LanguageMenu, with flag SVGs and accessible markup.
TypoScript DataProcessor
# Page/page.typoscript
page.10.dataProcessing {
# ... other processors
90 = B13\Menus\DataProcessing\LanguageMenu
90 {
as = languageNavigation
}
}Fluid Partial
Desktop (Dropdown)
<!-- PageView/Partials/Header/LanguageSwitcher.html -->
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-uri-known-prefixed-attribute="true">
<f:if condition="{languageNavigation -> f:count()} > 1">
<div class="language-switcher dropdown">
<f:for each="{languageNavigation}" as="lang">
<f:if condition="{lang.active}">
<button class="btn btn-sm dropdown-toggle language-switcher__toggle"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="{f:translate(key: 'changeLanguage', extensionName: 'my_sitepackage')}">
<img src="EXT:my_sitepackage/Resources/Public/Flags/{lang.twoLetterIsoCode}.svg"
alt=""
width="20"
height="15"
class="language-switcher__flag" />
<span class="language-switcher__label">{lang.twoLetterIsoCode}</span>
</button>
</f:if>
</f:for>
<ul class="dropdown-menu dropdown-menu-end language-switcher__menu">
<f:for each="{languageNavigation}" as="lang">
<f:if condition="!{lang.active}">
<li>
<f:if condition="{lang.available}">
<f:then>
<f:link.typolink parameter="{lang.link}" class="dropdown-item language-switcher__item"
hreflang="{lang.hreflang}" title="{lang.navigationTitle}">
<img src="EXT:my_sitepackage/Resources/Public/Flags/{lang.twoLetterIsoCode}.svg"
alt=""
width="20"
height="15"
class="language-switcher__flag" />
<span>{lang.navigationTitle}</span>
</f:link.typolink>
</f:then>
<f:else>
<span class="dropdown-item language-switcher__item disabled" aria-disabled="true">
<img src="EXT:my_sitepackage/Resources/Public/Flags/{lang.twoLetterIsoCode}.svg"
alt=""
width="20"
height="15"
class="language-switcher__flag" />
<span>{lang.navigationTitle}</span>
</span>
</f:else>
</f:if>
</li>
</f:if>
</f:for>
</ul>
</div>
</f:if>
</html>Mobile (List)
<!-- PageView/Partials/Header/LanguageSwitcherMobile.html -->
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-uri-known-prefixed-attribute="true">
<f:if condition="{languageNavigation -> f:count()} > 1">
<ul class="language-switcher-mobile list-unstyled d-flex gap-2">
<f:for each="{languageNavigation}" as="lang">
<li>
<f:if condition="{lang.available}">
<f:then>
<f:link.typolink parameter="{lang.link}"
class="language-switcher-mobile__item{f:if(condition: '{lang.active}', then: ' active')}"
hreflang="{lang.hreflang}"
aria-current="{f:if(condition: '{lang.active}', then: 'true')}"
aria-label="{lang.navigationTitle}">
<img src="EXT:my_sitepackage/Resources/Public/Flags/{lang.twoLetterIsoCode}.svg"
alt="{lang.navigationTitle}"
width="24"
height="18" />
</f:link.typolink>
</f:then>
<f:else>
<span class="language-switcher-mobile__item disabled" aria-disabled="true">
<img src="EXT:my_sitepackage/Resources/Public/Flags/{lang.twoLetterIsoCode}.svg"
alt="{lang.navigationTitle}"
width="24"
height="18"
class="opacity-50" />
</span>
</f:else>
</f:if>
</li>
</f:for>
</ul>
</f:if>
</html>SCSS
// Page/_language-switcher.scss
.language-switcher {
&__toggle {
display: flex;
align-items: center;
gap: 0.375rem;
background: transparent;
border: 1px solid $border-color;
border-radius: $border-radius;
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
text-transform: uppercase;
font-weight: $font-weight-bold;
color: $body-color;
&:hover {
border-color: $primary;
color: $primary;
}
}
&__flag {
border-radius: 0.125rem;
object-fit: cover;
}
&__menu {
min-width: auto;
}
&__item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
// Mobile variant
.language-switcher-mobile {
&__item {
display: block;
padding: 0.25rem;
border: 2px solid transparent;
border-radius: 0.25rem;
transition: border-color 0.2s ease;
&.active {
border-color: $primary;
}
&:hover:not(.disabled) {
border-color: $primary;
}
img {
display: block;
border-radius: 0.125rem;
}
}
}Flag SVGs
Place country flag SVGs in Resources/Public/Flags/:
Resources/Public/Flags/
├── de.svg # German
├── en.svg # English
├── fr.svg # French
├── es.svg # Spanish
└── ...Flag SVGs should use ISO 3166-1 alpha-2 codes (lowercase), matching the TYPO3 twoLetterIsoCode from the language configuration.
A good source for flag SVGs is the flag-icons npm package or similar collections. Ensure:
- Consistent dimensions (e.g., 4:3 aspect ratio)
- Optimized with SVGO
- No inline styles
Labels
<!-- locallang.xlf -->
<trans-unit id="changeLanguage"><source>Change language</source></trans-unit>
<!-- de.locallang.xlf -->
<trans-unit id="changeLanguage">
<source>Change language</source>
<target>Sprache wechseln</target>
</trans-unit>Key Points
- b13/menus LanguageMenu provides
available,active,link,hreflang,twoLetterIsoCode,navigationTitle - Unavailable languages are shown but disabled (greyed out)
- hreflang attribute on links for SEO
- aria-current on active language for accessibility
- Desktop vs. Mobile variants: dropdown for desktop, inline list for mobile nav
- Flag images use
alt=""when text label is present,alt="{lang}"when used alone
Pattern: Lazy Loading with IntersectionObserver
Pattern for initializing content elements only when they become visible in the viewport. Reduces initial JS execution and improves INP.
Core Utility
// TypeScript/Plugins/lazy-init.ts
interface LazyInitOptions {
rootMargin?: string;
threshold?: number;
once?: boolean;
}
/**
* Initialize a callback when elements matching the selector become visible.
* Uses IntersectionObserver for performance -- no scroll event listeners.
*/
export function lazyInit(
selector: string,
callback: (element: HTMLElement) => void,
options: LazyInitOptions = {},
): void {
const {
rootMargin = '200px 0px', // Start loading 200px before visible
threshold = 0,
once = true,
} = options;
const elements = document.querySelectorAll<HTMLElement>(selector);
if (elements.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
callback(entry.target as HTMLElement);
if (once) {
observer.unobserve(entry.target);
}
}
});
},
{ rootMargin, threshold },
);
elements.forEach((el) => observer.observe(el));
}Usage in Entrypoints
Slider (heavy JS library)
// Entrypoints/slider.entry.ts
import '../Scss/ContentElements/_slider.scss';
import { lazyInit } from '../TypeScript/Plugins/lazy-init';
document.addEventListener('DOMContentLoaded', () => {
lazyInit('.ce-slider', async (element) => {
// Dynamic import -- only loads Swiper when needed
const { default: Swiper } = await import('swiper');
const { Navigation, Pagination } = await import('swiper/modules');
new Swiper(element.querySelector('.swiper') as HTMLElement, {
modules: [Navigation, Pagination],
slidesPerView: 1,
spaceBetween: 16,
navigation: {
nextEl: element.querySelector('.swiper-button-next') as HTMLElement,
prevEl: element.querySelector('.swiper-button-prev') as HTMLElement,
},
pagination: {
el: element.querySelector('.swiper-pagination') as HTMLElement,
clickable: true,
},
});
});
});Video (external embed)
// Entrypoints/video.entry.ts
import '../Scss/ContentElements/_video.scss';
import { lazyInit } from '../TypeScript/Plugins/lazy-init';
document.addEventListener('DOMContentLoaded', () => {
lazyInit('.ce-video[data-src]', (element) => {
const src = element.dataset.src;
if (!src) return;
const iframe = document.createElement('iframe');
iframe.src = src;
iframe.setAttribute('allowfullscreen', '');
iframe.setAttribute('loading', 'lazy');
iframe.title = element.dataset.title || 'Video';
const container = element.querySelector('.ce-video__player');
if (container) {
// Clear placeholder and insert iframe
while (container.firstChild) {
container.removeChild(container.firstChild);
}
container.appendChild(iframe);
}
});
});Map (external library)
// Entrypoints/map.entry.ts
import '../Scss/ContentElements/_map.scss';
import { lazyInit } from '../TypeScript/Plugins/lazy-init';
document.addEventListener('DOMContentLoaded', () => {
lazyInit('.ce-map', async (element) => {
// Load map library only when the map container is visible
const { initMap } = await import('../TypeScript/Plugins/map');
initMap(element);
});
});Fluid Template Pattern
For video embeds, use a placeholder that gets replaced on visibility:
<!-- ContentElements/Templates/Video.html -->
<vite:asset entry="EXT:my_sitepackage/Resources/Private/Entrypoints/video.entry.ts" />
<div class="ce-video"
data-src="{data.tx_mask_video_url}"
data-title="{data.header}">
<div class="ce-video__player ratio ratio-16x9">
<f:if condition="{data.tx_mask_video_poster}">
<f:image image="{data.tx_mask_video_poster}" maxWidth="1200" loading="lazy" alt="" />
</f:if>
<button class="ce-video__play-btn" aria-label="{f:translate(key: 'playVideo', extensionName: 'my_sitepackage')}">
<f:image src="EXT:my_sitepackage/Resources/Public/Svg/play.svg" width="48" height="48" alt="" />
</button>
</div>
</div>SCSS
// Components/_lazy-placeholder.scss
// Generic placeholder for lazy-loaded content
[data-src]:not(.is-loaded) {
position: relative;
background-color: $gray-100;
min-height: 12rem;
// Loading indicator
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2rem;
height: 2rem;
margin: -1rem 0 0 -1rem;
border: 0.2rem solid $gray-300;
border-top-color: $primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
[data-src]:not(.is-loaded)::after {
animation: none;
border-top-color: $gray-300;
}
}Key Benefits
- No scroll listeners -- IntersectionObserver is GPU-accelerated and battery-friendly
- Dynamic imports -- heavy libraries (Swiper, map SDKs) load on demand
- 200px margin -- starts loading before visible, so users rarely see loading states
- Once mode -- observer disconnects after initialization, freeing memory
Pattern: Scroll to Anchor
Smooth scroll to anchor links with sticky header offset, history update, and deep link support.
TypeScript
// TypeScript/Plugins/scrollToAnchor.ts
const HEADER_SELECTOR = '#main-header';
const SCROLL_OFFSET = 16; // Additional padding in px
function getHeaderHeight(): number {
const header = document.querySelector<HTMLElement>(HEADER_SELECTOR);
return header ? header.offsetHeight : 0;
}
function scrollToElement(target: HTMLElement): void {
const offset = getHeaderHeight() + SCROLL_OFFSET;
const top = target.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({
top,
behavior: 'smooth',
});
// Move focus to target for accessibility
target.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
}
export function initScrollToAnchor(): void {
// Handle click on anchor links
document.addEventListener('click', (event: MouseEvent) => {
const link = (event.target as HTMLElement).closest<HTMLAnchorElement>('a[href^="#"]');
if (!link) return;
const hash = link.getAttribute('href');
if (!hash || hash === '#') return;
const target = document.querySelector<HTMLElement>(hash);
if (!target) return;
event.preventDefault();
scrollToElement(target);
// Update URL without triggering scroll
history.pushState(null, '', hash);
});
// Handle deep links (page load with hash)
if (window.location.hash) {
const target = document.querySelector<HTMLElement>(window.location.hash);
if (target) {
// Wait for layout to settle (fonts, images)
requestAnimationFrame(() => {
setTimeout(() => scrollToElement(target), 100);
});
}
}
}SCSS
// Basic/_scroll.scss
// Scroll margin for anchor targets (accounts for sticky header)
:target,
[id] {
scroll-margin-top: calc(var(--header-height, 5rem) + 1rem);
}
// Smooth scroll (CSS-only fallback)
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}Entrypoint
Add to main.entry.ts:
import { initScrollToAnchor } from '../TypeScript/Plugins/scrollToAnchor';
document.addEventListener('DOMContentLoaded', () => {
initScrollToAnchor();
});Playwright E2E Test
// tests/e2e/scroll-to-anchor.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Scroll to Anchor', () => {
test('scrolls to anchor with offset', async ({ page }) => {
await page.goto('/page-with-anchors');
await page.locator('a[href="#section-2"]').click();
await expect(page).toHaveURL(/#section-2/);
await expect(page.locator('#section-2')).toBeVisible();
});
test('handles deep links on page load', async ({ page }) => {
await page.goto('/page-with-anchors#section-2');
await expect(page.locator('#section-2')).toBeVisible();
});
});Pattern: Loading / Skeleton States
CSS-only skeleton placeholders for asynchronously loaded content.
SCSS
// Components/_skeleton.scss
.skeleton {
position: relative;
overflow: hidden;
background-color: $gray-200;
border-radius: $border-radius;
// Shimmer animation
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba($white, 0.4) 50%,
transparent 100%
);
animation: skeleton-shimmer 1.5s ease-in-out infinite;
transform: translateX(-100%);
}
}
@keyframes skeleton-shimmer {
100% {
transform: translateX(100%);
}
}
// Skeleton variants
.skeleton-text {
@extend .skeleton;
height: 1rem;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
&--short {
width: 60%;
}
&--medium {
width: 80%;
}
}
.skeleton-heading {
@extend .skeleton;
height: 1.5rem;
width: 40%;
margin-bottom: 1rem;
}
.skeleton-image {
@extend .skeleton;
aspect-ratio: 16 / 9;
width: 100%;
}
.skeleton-avatar {
@extend .skeleton;
width: 3rem;
height: 3rem;
border-radius: 50%;
}
.skeleton-button {
@extend .skeleton;
height: 2.5rem;
width: 8rem;
border-radius: $border-radius;
}
// Card skeleton
.skeleton-card {
border: 1px solid $border-color;
border-radius: $border-radius;
padding: 1rem;
.skeleton-image {
margin-bottom: 1rem;
}
}
// Reduced motion
@media (prefers-reduced-motion: reduce) {
.skeleton::after {
animation: none;
}
}HTML Templates
Card Grid Skeleton
<!-- Partials/Skeleton/CardGrid.html -->
<div class="row g-4" aria-busy="true" aria-label="{f:translate(key: 'loading', extensionName: 'my_sitepackage')}">
<f:for each="{0: 0, 1: 1, 2: 2}" as="i">
<div class="col-md-4">
<div class="skeleton-card">
<div class="skeleton-image"></div>
<div class="skeleton-heading"></div>
<div class="skeleton-text"></div>
<div class="skeleton-text skeleton-text--short"></div>
<div class="skeleton-button mt-3"></div>
</div>
</div>
</f:for>
</div>List Skeleton
<!-- Partials/Skeleton/List.html -->
<div aria-busy="true" aria-label="{f:translate(key: 'loading', extensionName: 'my_sitepackage')}">
<f:for each="{0: 0, 1: 1, 2: 2, 3: 3}" as="i">
<div class="d-flex gap-3 mb-3">
<div class="skeleton-avatar"></div>
<div class="flex-grow-1">
<div class="skeleton-text skeleton-text--medium"></div>
<div class="skeleton-text skeleton-text--short"></div>
</div>
</div>
</f:for>
</div>TypeScript Usage
// Show skeleton, load content, replace skeleton with result
export async function loadContent(
container: HTMLElement,
fetchFn: () => Promise<DocumentFragment>,
): Promise<void> {
const skeleton = container.querySelector('.skeleton-placeholder');
if (skeleton) {
skeleton.removeAttribute('hidden');
}
container.setAttribute('aria-busy', 'true');
try {
const fragment = await fetchFn();
if (skeleton) {
skeleton.setAttribute('hidden', '');
}
container.appendChild(fragment);
} catch {
if (skeleton) {
skeleton.setAttribute('hidden', '');
}
const error = document.createElement('p');
error.className = 'text-danger';
error.textContent = 'Content could not be loaded.';
container.appendChild(error);
} finally {
container.removeAttribute('aria-busy');
}
}Accessibility
aria-busy="true"on containers while loadingaria-labelwith loading text for screen readers- Skeleton elements are purely decorative (no ARIA roles needed)
- Animation respects
prefers-reduced-motion
Pattern: Sticky Header
Complete pattern for a scroll-triggered sticky header with performance-optimized event handling.
TypeScript
// TypeScript/Plugins/stickyheader.ts
const SCROLL_THRESHOLD = 100;
const HEADER_SELECTOR = '#main-header';
const STICKY_CLASS = 'stickyheader';
export function initStickyHeader(): void {
const header = document.querySelector<HTMLElement>(HEADER_SELECTOR);
if (!header) return;
let lastScrollY = 0;
let ticking = false;
const update = (): void => {
const scrollY = window.scrollY;
if (scrollY > SCROLL_THRESHOLD) {
header.classList.add(STICKY_CLASS);
} else {
header.classList.remove(STICKY_CLASS);
}
lastScrollY = scrollY;
ticking = false;
};
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(update);
ticking = true;
}
}, { passive: true });
// Initial check (page might load scrolled)
update();
}SCSS
// Page/_header.scss
.main-header {
position: relative;
z-index: 1030; // Above Bootstrap dropdowns
transition: transform 0.3s ease, box-shadow 0.3s ease;
&.stickyheader {
position: fixed;
top: 0;
left: 0;
right: 0;
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
background-color: $white;
animation: slideDown 0.3s ease;
}
}
@keyframes slideDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
// Compensate for fixed header height
body.has-sticky-header {
padding-top: var(--header-height, 5rem);
}
// Respect reduced motion
@media (prefers-reduced-motion: reduce) {
.main-header {
transition: none;
&.stickyheader {
animation: none;
}
}
}Entrypoint Integration
The sticky header is typically part of main.entry.ts (loaded on every page):
// Entrypoints/main.entry.ts
import { initStickyHeader } from '../TypeScript/Plugins/stickyheader';
document.addEventListener('DOMContentLoaded', () => {
initStickyHeader();
});Playwright E2E Test
// tests/e2e/sticky-header.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Sticky Header', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('is not sticky at top of page', async ({ page }) => {
await expect(page.locator('#main-header')).not.toHaveClass(/stickyheader/);
});
test('becomes sticky when scrolling past threshold', async ({ page }) => {
await page.evaluate(() => window.scrollTo(0, 200));
await expect(page.locator('#main-header')).toHaveClass(/stickyheader/);
});
test('removes sticky when scrolling back to top', async ({ page }) => {
await page.evaluate(() => window.scrollTo(0, 200));
await page.evaluate(() => window.scrollTo(0, 0));
await expect(page.locator('#main-header')).not.toHaveClass(/stickyheader/);
});
});Advanced: Hide on Scroll Down, Show on Scroll Up
export function initStickyHeaderAdvanced(): void {
const header = document.querySelector<HTMLElement>(HEADER_SELECTOR);
if (!header) return;
let lastScrollY = 0;
let ticking = false;
const update = (): void => {
const scrollY = window.scrollY;
if (scrollY > SCROLL_THRESHOLD) {
header.classList.add(STICKY_CLASS);
// Hide on scroll down, show on scroll up
if (scrollY > lastScrollY) {
header.classList.add('header-hidden');
} else {
header.classList.remove('header-hidden');
}
} else {
header.classList.remove(STICKY_CLASS, 'header-hidden');
}
lastScrollY = scrollY;
ticking = false;
};
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(update);
ticking = true;
}
}, { passive: true });
}.main-header.header-hidden {
transform: translateY(-100%);
}Pattern: Toast / Notification
Lightweight notification system for form feedback and user messages, without jQuery.
TypeScript
// TypeScript/Plugins/toast.ts
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface ToastOptions {
message: string;
type?: ToastType;
duration?: number; // ms, 0 = manual dismiss
ariaLive?: 'polite' | 'assertive';
}
const CONTAINER_ID = 'toast-container';
const DEFAULT_DURATION = 5000;
function getOrCreateContainer(): HTMLElement {
let container = document.getElementById(CONTAINER_ID);
if (!container) {
container = document.createElement('div');
container.id = CONTAINER_ID;
container.className = 'toast-container';
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-atomic', 'false');
document.body.appendChild(container);
}
return container;
}
function createIcon(type: ToastType): SVGElement {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '20');
svg.setAttribute('height', '20');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
const paths: Record<ToastType, string[]> = {
success: ['M20 6L9 17l-5-5'],
error: ['M12 12m-10 0a10 10 0 1020 0a10 10 0 10-20 0', 'M15 9l-6 6', 'M9 9l6 6'],
warning: ['M12 9v4', 'M12 17h.01'],
info: ['M12 12m-10 0a10 10 0 1020 0a10 10 0 10-20 0', 'M12 16v-4', 'M12 8h.01'],
};
paths[type].forEach((d) => {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
svg.appendChild(path);
});
return svg;
}
export function showToast(options: ToastOptions): void {
const {
message,
type = 'info',
duration = DEFAULT_DURATION,
ariaLive,
} = options;
const container = getOrCreateContainer();
container.setAttribute('aria-live', ariaLive ?? (type === 'error' ? 'assertive' : 'polite'));
const toast = document.createElement('div');
toast.className = `toast toast--${type}`;
toast.setAttribute('role', 'status');
// Icon
const iconWrap = document.createElement('span');
iconWrap.className = 'toast__icon';
iconWrap.appendChild(createIcon(type));
toast.appendChild(iconWrap);
// Message
const text = document.createElement('span');
text.className = 'toast__message';
text.textContent = message;
toast.appendChild(text);
// Close button
const closeBtn = document.createElement('button');
closeBtn.className = 'toast__close';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.textContent = '\u00d7';
closeBtn.addEventListener('click', () => removeToast(toast));
toast.appendChild(closeBtn);
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add('toast--visible');
});
if (duration > 0) {
setTimeout(() => removeToast(toast), duration);
}
}
function removeToast(toast: HTMLElement): void {
toast.classList.remove('toast--visible');
toast.classList.add('toast--exit');
toast.addEventListener('transitionend', () => {
toast.remove();
}, { once: true });
// Fallback if transition doesn't fire
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 400);
}
// Convenience methods
export const toast = {
success: (message: string, duration?: number) =>
showToast({ message, type: 'success', duration }),
error: (message: string, duration?: number) =>
showToast({ message, type: 'error', duration: duration ?? 0 }),
warning: (message: string, duration?: number) =>
showToast({ message, type: 'warning', duration }),
info: (message: string, duration?: number) =>
showToast({ message, type: 'info', duration }),
};SCSS
// Components/_toast.scss
.toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1090;
display: flex;
flex-direction: column-reverse;
gap: 0.5rem;
max-width: 24rem;
pointer-events: none;
@media (max-width: map-get($grid-breakpoints, sm) - 0.02px) {
left: 1rem;
right: 1rem;
max-width: none;
}
}
.toast {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-radius: $border-radius;
background-color: $white;
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.15);
border-left: 0.25rem solid $gray-400;
pointer-events: auto;
opacity: 0;
transform: translateY(1rem);
transition: opacity 0.3s ease, transform 0.3s ease;
&--visible {
opacity: 1;
transform: translateY(0);
}
&--exit {
opacity: 0;
transform: translateX(100%);
}
&--success { border-left-color: $success; .toast__icon { color: $success; } }
&--error { border-left-color: $danger; .toast__icon { color: $danger; } }
&--warning { border-left-color: $warning; .toast__icon { color: $warning; } }
&--info { border-left-color: $info; .toast__icon { color: $info; } }
&__icon {
flex-shrink: 0;
display: flex;
margin-top: 0.125rem;
}
&__message {
flex-grow: 1;
font-size: 0.875rem;
line-height: 1.4;
}
&__close {
flex-shrink: 0;
background: none;
border: none;
font-size: 1.25rem;
line-height: 1;
color: $text-muted;
cursor: pointer;
padding: 0;
&:hover { color: $body-color; }
}
}
@media (prefers-reduced-motion: reduce) {
.toast { transition: none; }
}Usage
import { toast } from '../TypeScript/Plugins/toast';
toast.success('Formular erfolgreich gesendet.');
toast.error('Ein Fehler ist aufgetreten.');
toast.warning('Sitzung läuft bald ab.');
toast.info('Neue Inhalte verfügbar.', 8000);Accessibility
aria-live="polite"for success/info/warningaria-live="assertive"for errorsrole="status"on each toast- Error toasts stay until manually dismissed (
duration: 0)