Drei Actions, die in fast jedem Svelte-Projekt früher oder später nötig sind — hier als vollständige, fertig kopierbare Implementierungen mit Erklärung jeder Zeile. Tooltip für Hilfetexte, ClickOutside für selbstschließende Menüs, Focus-Trap für barrierefreie Modal-Dialoge. Alles TypeScript-typisiert, alles mit Cleanup und mit Blick auf Accessibility.

Tooltip

Ein Tooltip soll bei Hover und bei Tastatur-Fokus erscheinen — letzteres ist a11y-Pflicht. Wir bauen das mit einer Action, die einen kleinen Popover-Knoten ans Body anhängt und beim Verlassen wieder entfernt.

ts src/lib/actions/tooltip.svelte.ts
import type { Action } from 'svelte/action';

type TooltipParams = {
    text: string;
    placement?: 'top' | 'bottom';
};

export const tooltip: Action<HTMLElement, TooltipParams> = (node, params) => {
    let popup: HTMLDivElement | null = null;

    function show() {
        if (popup) return;
        popup = document.createElement('div');
        popup.className = 'tooltip-popup';
        popup.textContent = params.text;
        popup.setAttribute('role', 'tooltip');
        document.body.appendChild(popup);

        const rect = node.getBoundingClientRect();
        const placement = params.placement ?? 'top';
        popup.style.position = 'fixed';
        popup.style.left = `${rect.left + rect.width / 2}px`;
        popup.style.top =
            placement === 'top'
                ? `${rect.top - 8}px`
                : `${rect.bottom + 8}px`;
        popup.style.transform = 'translate(-50%, -100%)';
    }

    function hide() {
        popup?.remove();
        popup = null;
    }

    $effect(() => {
        node.addEventListener('mouseenter', show);
        node.addEventListener('mouseleave', hide);
        node.addEventListener('focus', show);
        node.addEventListener('blur', hide);

        return () => {
            node.removeEventListener('mouseenter', show);
            node.removeEventListener('mouseleave', hide);
            node.removeEventListener('focus', show);
            node.removeEventListener('blur', hide);
            hide();
        };
    });
};

Was hier wichtig ist:

  1. Hover und Focus behandeln — Tastatur-Nutzer dürfen nicht ausgegrenzt werden.
  2. role="tooltip" für Screenreader.
  3. position: fixed mit Berechnung über getBoundingClientRect() — das Tooltip lebt im <body>, nicht im Container, sodass overflow: hidden an Eltern-Elementen ihn nicht abschneidet.
  4. Beim Cleanup: alle vier Listener entfernen und den Popover wieder aus dem DOM nehmen.

Verwendung

svelte Tooltip am Button
<script>
    import { tooltip } from '$lib/actions/tooltip.svelte';
</script>

<button use:tooltip={{ text: 'Eintrag speichern' }}>
    Speichern
</button>

ClickOutside

Ein häufig gebrauchtes Pattern: Ein Dropdown soll sich schließen, wenn der Nutzer außerhalb klickt. Mit einer Action wird das ein Einzeiler in der Verwendung.

ts src/lib/actions/click-outside.svelte.ts
import type { Action } from 'svelte/action';

type ClickOutsideCallback = () => void;

export const clickOutside: Action<HTMLElement, ClickOutsideCallback> = (node, callback) => {
    $effect(() => {
        let onClick = callback;

        function handle(event: MouseEvent) {
            if (!node.contains(event.target as Node)) {
                onClick();
            }
        }

        // capture: true → früher Phase; verhindert Konflikte mit
        // anderen Listenern, die `stopPropagation` aufrufen.
        document.addEventListener('click', handle, { capture: true });

        return () => document.removeEventListener('click', handle, { capture: true });
    });
};

Schritt für Schritt:

  1. Globaler Listener auf document — fängt jeden Klick ab.
  2. node.contains(event.target) prüft, ob der Klick innerhalb des Action-Elements war. Falls nicht, ist es „außerhalb" — Callback ausführen.
  3. capture: true fängt das Event in der Capture-Phase, bevor Kind-Listener mit stopPropagation() es schlucken könnten. Das ist die typische Stolperfalle bei dieser Pattern-Implementierung.

Verwendung

svelte Dropdown
<script>
    import { clickOutside } from '$lib/actions/click-outside.svelte';

    let open = $state(false);
</script>

<div use:clickOutside={() => open = false}>
    <button onclick={() => open = !open}>Menü</button>

    {#if open}
        <ul>
            <li>Eintrag 1</li>
            <li>Eintrag 2</li>
        </ul>
    {/if}
</div>

Klick auf den Button toggelt das Menü. Klick irgendwo anders schließt es automatisch.

Focus-Trap

Ein Focus-Trap hält den Tastatur-Fokus innerhalb eines bestimmten Bereichs — typisch für Modal-Dialoge. Ohne Trap kann der Nutzer mit Tab aus dem Modal raus auf darunterliegende Elemente, die er gar nicht sehen sollte. Das ist ein klassischer a11y-Fehler.

ts src/lib/actions/focus-trap.svelte.ts
import type { Action } from 'svelte/action';

export const focusTrap: Action<HTMLElement> = (node) => {
    $effect(() => {
        const previousActive = document.activeElement as HTMLElement | null;

        function getFocusable(): HTMLElement[] {
            return Array.from(
                node.querySelectorAll<HTMLElement>(
                    'a[href], button:not([disabled]), input:not([disabled]), ' +
                        'select:not([disabled]), textarea:not([disabled]), ' +
                        '[tabindex]:not([tabindex="-1"])',
                ),
            );
        }

        function handleKey(event: KeyboardEvent) {
            if (event.key !== 'Tab') return;

            const focusable = getFocusable();
            if (focusable.length === 0) {
                event.preventDefault();
                return;
            }

            const first = focusable[0];
            const last = focusable[focusable.length - 1];

            if (event.shiftKey && document.activeElement === first) {
                event.preventDefault();
                last.focus();
            } else if (!event.shiftKey && document.activeElement === last) {
                event.preventDefault();
                first.focus();
            }
        }

        node.addEventListener('keydown', handleKey);

        // Initialer Fokus auf das erste fokussierbare Element
        getFocusable()[0]?.focus();

        return () => {
            node.removeEventListener('keydown', handleKey);
            previousActive?.focus(); // Fokus zurück, wo er vorher war
        };
    });
};

Was hier passiert:

  1. previousActive merken — das Element, das vor dem Trap den Fokus hatte. Bei Cleanup geben wir den Fokus dorthin zurück (sonst verschwindet er ins Nichts).
  2. Fokussierbare Elemente im Container ermitteln — Standard-Selector aus der Web-Accessibility-Welt.
  3. Tab-Handler bei Bedarf abfangen: Tab am letzten Element springt zurück zum ersten, Shift+Tab am ersten zum letzten. So bleibt der Fokus im Modal gefangen.
  4. Initial-Fokus auf das erste Element setzen — damit der Tab-Cycle direkt funktioniert.

Verwendung

svelte Modal mit Focus-Trap
<script>
    import { focusTrap } from '$lib/actions/focus-trap.svelte';

    let { open = $bindable(false) } = $props();
</script>

{#if open}
    <div class="overlay" onclick={() => open = false}>
        <div
            class="dialog"
            role="dialog"
            aria-modal="true"
            onclick={(e) => e.stopPropagation()}
            use:focusTrap
        >
            <h2>Eintrag löschen?</h2>
            <p>Diese Aktion kann nicht rückgängig gemacht werden.</p>
            <div class="actions">
                <button onclick={() => open = false}>Abbrechen</button>
                <button onclick={() => open = false}>Löschen</button>
            </div>
        </div>
    </div>
{/if}

Sobald der Modal sichtbar wird, übernimmt der Trap. Der Nutzer kann den Modal nur über die im Markup vorgesehenen Wege verlassen (Buttons, Outside-Click, Escape — Letzteres müsstest du noch ergänzen).

Bonus: Auto-Resize-Textarea

Ein praktisches kleines Beispiel, das man in fast jedem Form-Builder braucht: Eine Textarea, deren Höhe sich automatisch dem Inhalt anpasst.

ts src/lib/actions/auto-resize.svelte.ts
import type { Action } from 'svelte/action';

export const autoResize: Action<HTMLTextAreaElement> = (node) => {
    $effect(() => {
        function resize() {
            node.style.height = 'auto';
            node.style.height = `${node.scrollHeight}px`;
        }

        resize();
        node.addEventListener('input', resize);

        return () => node.removeEventListener('input', resize);
    });
};
svelte Verwendung
<script>
    import { autoResize } from '$lib/actions/auto-resize.svelte';
</script>

<textarea use:autoResize bind:value={text} rows="2"></textarea>

Bei jeder Eingabe setzt die Action die Höhe zurück auf auto (sonst kann die Textarea nicht kleiner werden) und dann auf den scrollHeight — die natürliche Inhaltshöhe.

Bonus: Long-Press

Ein Tap-Halten erkennen — z. B. für Kontextmenüs oder Mehrfach-Selektion auf Touch-Geräten.

ts src/lib/actions/long-press.svelte.ts
import type { Action } from 'svelte/action';

type LongPressParams = {
    duration?: number;
    onPress: () => void;
};

export const longPress: Action<HTMLElement, LongPressParams> = (node, params) => {
    $effect(() => {
        let timeoutId: number | undefined;

        function start() {
            timeoutId = window.setTimeout(() => {
                params.onPress();
            }, params.duration ?? 500);
        }

        function cancel() {
            if (timeoutId) clearTimeout(timeoutId);
        }

        node.addEventListener('pointerdown', start);
        node.addEventListener('pointerup', cancel);
        node.addEventListener('pointerleave', cancel);

        return () => {
            cancel();
            node.removeEventListener('pointerdown', start);
            node.removeEventListener('pointerup', cancel);
            node.removeEventListener('pointerleave', cancel);
        };
    });
};
svelte Verwendung
<script>
    import { longPress } from '$lib/actions/long-press.svelte';

    function handleLongPress() {
        console.log('Lange gedrückt!');
    }
</script>

<button use:longPress={{ onPress: handleLongPress, duration: 800 }}>
    Halten
</button>

Der pointerdown-Event startet einen Timer. Wird die Maus losgelassen oder verlässt das Element, bricht der Timer ab. Läuft er durch, wurde lange genug gedrückt — Callback feuert.

Häufige Stolperfallen bei Praxis-Actions

Tooltip am Container statt am Body anhängen.

Wenn der Container overflow: hidden hat, wird der Tooltip beschnitten. Anhängen an document.body (oder <svelte:head>) löst das.

ClickOutside ohne capture.

Listener im Inneren mit stopPropagation() können den Outside-Detect unterbrechen. capture: true fängt früher.

Focus-Trap ohne Initial-Fokus.

Ein Modal ohne automatisch gesetzten Initial-Fokus ist für Tastatur-Nutzer praktisch unbenutzbar.

Vergessen, den Fokus beim Cleanup zurückzugeben.

Wenn der Modal schließt und kein previousActive.focus() läuft, landet der Fokus im Nichts (oft am <body>). Sehr unangenehm für Tastatur-Nutzer.

Auto-Resize ohne auto-Reset.

textarea.style.height = scrollHeight macht die Box nur größer. Der vorherige Reset auf auto ist nötig, damit sie wieder kleiner werden kann.

/ Weiter

Zurück zu Actions

Zur Übersicht