První díl seriálu se týká navigace: jak z webu v Symfony udělat stránky, mezi kterými se přechází bez načítání celé stránky, a na co si dát pozor. Formuláře jsou ve druhém dílu, Alpine.js ve třetím. Platí pro htmx 4.
Principy
Server vrací hotové HTML a htmx ho vkládá do stránky. Není potřeba API pro frontend ani druhá vrstva stavu a validace, šablony, formuláře i security zůstávají v Symfony.
Každá interakce má stejný průběh: trigger → request → response → výběr části odpovědi (hx-select) → swap do DOM → settle → zápis do historie. Atributy určují, co se v jednotlivých krocích stane.
Předem je dobré vědět:
- Dědičnost je explicitní. Potomkům se předá jen atribut se sufixem
:inherited. Bez něj se odkazy neboostují a žádná chyba se neobjeví. - Extensions se aktivují importem, žádný atribut na ně není potřeba.
- Události mají tvar
htmx:fáze:akce, napříkladhtmx:before:swap. Listener s překlepem v názvu se tiše nespustí. - Swapuje se všechno kromě 204 a 304, včetně error pages.
- Historie neukládá kopie stránek. Krok zpět stránku znovu stáhne ze serveru.
- Morph je přímo v jádře.
Atributy, události a hlavičky v tomto seriálu jen vyjmenujeme u konkrétních situací. Popis všech je v referenci htmx 4.
Instalace
npm install htmx.org@4
Vstupní soubor je stejný pro Vite i Webpack Encore:
import htmx from 'htmx.org'
// Extensions se registrují na globální htmx, proto až za jádrem.
import 'htmx.org/dist/ext/hx-preload.js'
import 'htmx.org/dist/ext/hx-head.js'
import registerScrollRestore from './js/scroll-restore.js'
htmx.config.transitions = !matchMedia('(prefers-reduced-motion: reduce)').matches
registerScrollRestore()
htmx.initialize()volat nemusíte, htmx se spustí samo po vyhodnocení modulu. Extensions i listenery registrujte hned v modulu.- Skripty patří do
<head>. Při morphu obsahu<body>by se skript na konci stránky choval jako obyčejný prvek.
Navigace
Základní nastavení
Většině webů stačí jedno nastavení na <body>:
<body hx-boost:inherited="true"
hx-swap:inherited="innerMorph"
hx-head:inherited="merge">
Controllery se nemění a vracejí celé stránky. Stejně může běžet i hledání s filtry a stránkováním jako obyčejné GET formuláře a odkazy, bez fragmentů a bez čtení hlaviček na serveru.
hx-swap:inherited zdědí i prvky s vlastním hx-get nebo hx-post, ty proto mají mít vlastní hx-swap.
Když ve stránce musí zůstat něco mimo swap, například prvky vložené cizím skriptem, swapujte jen hlavní kontejner:
<head hx-head="merge">
<body hx-boost:inherited="target:'#app', select:'#app', swap:'outerMorph ignoreTitle:true'">
<div id="app" hx-history-elt>
hx-head="merge"je tu nutné. Když target není<body>,hx-headhlavičku jen doplňuje a titulky i meta tagy se hromadí.- Target uveďte selektorem, ne
target:this. hx-targetanihx-selectnedávejte na#app, zdědili by je všichni potomci.- Bez
hx-history-eltby krok zpět vyměnil celé<body>.
Co boostovat a co ne
Boost se týká odkazů a formulářů na stejném originu. Vynechá odkazy s download, s jiným target než _self a odkazy začínající #. Klik s Ctrl, Cmd nebo Shiftem nechá na prohlížeči.
hx-boost="false" patří na:
- odkazy do administrace nebo jiné části s vlastními assety,
- stránky s úplně jiným layoutem, například přihlášení bez hlavičky a patičky,
- přihlašovací formulář a odkazy, které něco mění (odhlášení přes GET),
- přepínač, který řeší stav jen v prohlížeči.
Morph mezi nesourodými layouty je zbytečné riziko, plné načtení je spolehlivější.
Morph a identita prvků
Morph páruje prvky podle tagu a id. Prvek bez id se sloučí s jakýmkoli prvkem se stejným tagem na stejném místě. Prvek s id se sloučí jen s prvkem se stejným id, jinak se vloží jako nový a starý zmizí.
Osvědčené je dát hlavnímu kontejneru obsahu id podle routy:
<main id="content-{{ app.request.attributes.get('_route')|default('page') }}">
{% block content %}{% endblock %}
</main>
Stejná routa se morphne a stav zůstane, jiná routa obsah čistě vymění. Navigace, sidebar a patička zůstanou mimo kontejner a jejich stav přežije přechod. Na co si dát pozor:
- Kontejner bez
idnebo se společnýmidpřenáší stav mezi nesouvisejícími prvky. Text z polesearchse objeví v poliemailna další stránce, heslo se ukáže čitelně v textovém poli (morph změnítype, hodnotu nechá) a listener pověšený přímo na tlačítko „Smazat“ se spustí po kliknutí na nové tlačítko „Sdílet“. - Stejná vnitřní
idpod různými kontejnery. Když má prvek uvnitř kontejneru na obou stránkách stejnéid, morph ho přenese i se stavem do nového kontejneru a komponenta v něm může přijít o stav.iduvnitř stránky proto volte tak, aby se mezi různými stránkami neopakovala, pokud prvek nemá přechod přežít úmyslně. - Formuláře mají mít vlastní
id, u přihlašovacího formuláře je to nutnost. - Listenery věšte přes delegaci na
document, nebo je před swapem odstraňte. - Morph maže atributy, které v odpovědi nejsou. Příznak jako
data-map-initializednastavený v prohlížeči zmizí a widget se inicializuje podruhé. Přidejte ho dohtmx.config.morphIgnore(výchozí pole rozšiřte, nepřepisujte), nebo stav zjišťujte přes knihovnu, napříkladChart.getChart(canvas). - Morph zachovává hodnoty polí, takže se nehodí na reset formuláře (viz díl 2).
- Názvy atributů musí projít přes
setAttribute(). Morph jím kopíruje atributy a WebKit odmítne název, který parser HTML přijme, například@clickz Alpine.js. Morph pak skončí v půlce bez chyby v konzoli. Podrobnosti jsou ve třetím dílu.
Prvek vyjmete z morphu atributem hx-morph-skip, hx-morph-skip-children aktualizuje jen jeho atributy.
Head, titulek a jazyk
Extension hx-head sloučí <head> podle přesné shody tagů. Titulek, description, canonical i JSON-LD se po přechodu vymění.
- Analytiku a další jednorázové skripty v hlavičce označte
hx-preserve="true", jinak by se při každém přechodu mazaly a spouštěly znovu. <html lang>se nemění, protože htmx swapuje jen obsah<body>. Po přepnutí jazyka by zůstal starý jazyk pro čtečky i překladače. Přepínači jazyka dejtehx-boost="false", nebolangnastavte z odpovědi:
document.addEventListener('htmx:before:swap', (event) => {
const lang = event.detail.ctx.text?.match(/<html[^>]*\slang="([^"]*)"/i)?.[1]
if (lang) document.documentElement.lang = lang
})
- htmx zapisuje titulek až po swapu, se zapnutými View Transitions až po doběhnutí přechodu. Druhý rychlý klik pak může v záložce nechat titulek první stránky.
ignoreTitle:truevhx-swapnechá titulek jen nahx-head, který ho nastaví hned. - CSS nenačítejte z lazy importovaného JavaScriptu. Vite ho vloží do
<head>, další přechod ho jako neznámý odstraní a znovu se nepřidá. Lazy načítejte jen JavaScript.
Menu po přechodu
Rozbalovací menu na :hover a :focus-within zůstane po kliknutí na odkaz v něm otevřené, protože odkaz si fokus podrží i po přechodu. Při plném načtení stránky se fokus ztratí sám, s htmx je potřeba ho sundat:
document.addEventListener('htmx:before:request', (event) => {
if (event.detail.ctx.request.headers['HX-Boosted']) document.activeElement?.blur()
})
Prefetch
Extension hx-preload stáhne stránku už při mousedown nebo touchstart. Než uživatel pustí tlačítko, je obsah na cestě nebo hotový. Pro boostované odkazy stačí extension načíst, atribut je potřeba jen pro změnu chování:
<body hx-preload:inherited="mousedown, touchstart">
Prefetch je skutečný request a nepoužívá HTTP cache, takže kvůli němu nemusíte nastavovat Cache-Control. Týká se jen GET.
Akce, které něco mění, z prefetche vyjměte. Prefetch nerozlišuje tlačítka myši a spustí se i pravým klikem, prostředním klikem nebo začátkem posunu na mobilu. Odkaz přes GET by tak provedl akci, kterou uživatel nechtěl:
- odhlášení,
- přidání do košíku nebo do oblíbených,
- smazání, archivace, označení jako přečtené,
- hlasování, potvrzení nebo zrušení odběru,
- přepnutí jazyka nebo měny uložené do session,
- export, který se na serveru drahý generuje,
- cokoli, co vyzvedne flash zprávu.
Nejčistší je takové akce dělat formulářem přes POST s CSRF tokenem, prefetch se POST netýká. Když musí zůstat odkazem, vyjměte ho: hx-preload="none" vypne jen prefetch a boost nechá, hx-boost="false" vypne obojí. Odhlášení a přepnutí jazyka patří spíš k hx-boost="false", protože mění celou stránku.
Další důsledky:
- Počítadla zobrazení mírně nadhodnocují, započítá se i rozmyšlené stisknutí. Prefetch na serveru nefiltrujte: po kliknutí bývá jediným requestem, takže by zmizela skoro všechna skutečná zobrazení. Na seznamu, kde gesto posunu často začíná na odkazu, zužte triggery přes
hx-preload:inherited="mousedown". - Výchozích 5 sekund platnosti neprodlužujte tam, kde je osobní stav. Odkaz bez
idsi morph ponechá i se staženou odpovědí, takže delší platnost by po nákupu ukázala starý zůstatek. Na čistě obsahovém webu lze prodloužit, napříkladmousedown timeout:30s. - Prefetch na hover se nevyplatí. Extension nemá prodlevu, takže přejetí přes menu vyrenderuje všechny stránky za ním. Vlastní prodleva vedla k dvojitým requestům a proti
mousedownpřinesla jen desítky milisekund.
View Transitions
htmx.config.transitions = true zapne View Transitions. S výchozí délkou trvá přechod asi 300 ms, což na webu s prefetchem působí pomaleji, než web je. Zkraťte všechny tři pseudo-elementy, délku celého přechodu určuje group:
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 100ms;
}
- Při
prefers-reduced-motionpřechody vůbec nezapínejte. Samotný snímek stránku na dobu přechodu zmrazí, i když je animace v CSS vypnutá. - Prvek s vlastním
view-transition-namese animuje zvlášť. Podtržení aktivní položky menu jako samostatný<span>s vlastním jménem tak mezi stránkami plynule přejede, včetně změny šířky. Jméno musí být v dokumentu jedinečné, dvě stejná jména přechod zruší. - Nepoužívejte globální
scroll-behavior: smooth. Stránka by se po každém přechodu a kroku zpět plynule odvíjela. Smooth scroll zapínejte jen u konkrétních odkazů.
Error pages
htmx swapne i chybovou odpověď, takže boostovaný odkaz na neexistující stránku ukáže vaši stránku 404.
- Šablony v
templates/bundles/TwigBundle/Exception/musí mít celý layout. - V prostředí
devatestse nevykreslí, protože běžíkernel.debug. Zkoušejte je na buildu proprod. - U varianty s
#appby odpověď bez#appsmazala obsah a zůstala by prázdná stránka. Stane se to při 502 z proxy během nasazení, při fatální chybě PHP nebo u fallback error page Symfony. Pojistka předá navigaci prohlížeči:
document.addEventListener('htmx:before:swap', (event) => {
const { ctx, tasks } = event.detail
if (ctx.target?.id !== 'app') return
const main = tasks.find((task) => task.type === 'main')
if (main && main.fragment.childElementCount === 0) {
event.preventDefault()
window.location.assign(ctx.request.action)
}
})
Expirovaná session
Když session expiruje, Symfony přesměruje na login a fetch redirect sám následuje. Pro boostovaný odkaz s loginem ve stejném layoutu to funguje bez úprav. Když má login jiný layout nebo se načítá fragment s vlastním targetem, vložila by se přihlašovací stránka do cizího místa. Pomůže, když AuthenticationEntryPoint u htmx vynutí plné načtení:
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
$loginUrl = $this->urlGenerator->generate('app_login');
if ($request->headers->has('HX-Request')) {
return new Response('', Response::HTTP_OK, ['HX-Redirect' => $loginUrl]);
}
return new RedirectResponse($loginUrl);
}
Symfony si před tímto voláním uloží adresu requestu jako cíl po přihlášení. U fragmentu přes GET by se uživatel po přihlášení dostal na holý fragment, v této větvi proto cíl smažte přes TargetPathTrait::removeTargetPath().
Krok zpět a posun
htmx si při přechodu neukládá kopii opuštěné stránky, takže ji krok zpět stahuje ze serveru znovu. Back/forward cache prohlížeče nepomůže, funguje jen při přechodu mezi dokumenty.
- V prohlížeči s Navigation API htmx navigaci zachytí, stránku stáhne a swapne. Ve Firefoxu a prohlížečích bez tohoto API stejnou práci spouští
popstate. - Request nese jen
HX-History-Restore-Request, chybíHX-RequestiHX-Boosted. Server tak vrátí celou stránku bez úprav. - Swap jde přes
outerSync, ne přes morph. Stav komponent a rozepsaná pole se ztratí.
Z toho plyne:
- Stav, který má přežít krok zpět, patří do URL. Filtr, stránka i hledání musí být v adrese. Fragment s
hx-push-urlmusí mít adresu, na které server vrátí celou stránku. - Widgety se musí umět zrušit a vytvořit znovu. Grafy rušte v
htmx:before:swapa vytvářejte vhtmx.onLoad. - Úklid stavu mimo swap patří taky do
htmx:before:swap, například odstranění třídy, která zamyká posun při otevřeném menu. - Bez requestu na server jde krok zpět s extension
hx-history-cache, které stránky ukládá dosessionStorage, takže Zpět nepošle žádný request. Daní je, že data po návratu nemusí být čerstvá.
Scroll restoration
Prohlížeč obnoví posun až po skončení zachycené navigace, takže se stránka nejdřív vykreslí nahoře a pak skočí dolů. Firefox to má naopak: posune ještě starý obsah a z delší stránky na kratší se posun po swapu ořízne. Řešení je pamatovat si posun podle klíče položky historie:
const STORAGE_KEY = 'scroll-positions'
const LIMIT = 50
function load() {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {}
} catch (e) {
return {}
}
}
function store(positions) {
const keys = Object.keys(positions)
for (const key of keys.slice(0, Math.max(0, keys.length - LIMIT))) delete positions[key]
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
} catch (e) { /* privátní okno: posun se obnoví jen do reloadu */ }
}
export default function registerScrollRestore() {
if (!window.navigation) return
const positions = load()
let pending = null
// Stejná podmínka, za které htmx místo zachycení navigace poslouchá popstate.
if (/firefox/i.test(navigator.userAgent)) {
history.scrollRestoration = 'manual'
const isHistoryLoad = performance.getEntriesByType('navigation')[0]?.type === 'back_forward'
const target = positions[navigation.currentEntry.key]
if (isHistoryLoad && target !== undefined) window.scrollTo(0, target)
// Odchod přes adresní řádek událost navigate nespustí.
window.addEventListener('pagehide', () => {
positions[navigation.currentEntry.key] = window.scrollY
store(positions)
})
}
// Musí se registrovat dřív než listener htmx, jinak scroll: 'manual' nepřijde včas.
navigation.addEventListener('navigate', (event) => {
positions[navigation.currentEntry.key] = window.scrollY
store(positions)
const isHistoryRestore = event.navigationType === 'traverse' && event.canIntercept && !event.hashChange
// Položky se stejnou adresou přidává modální okno: stránka se nemění a swap nepřijde.
if (isHistoryRestore && event.destination.url === navigation.currentEntry.url) {
event.intercept({ scroll: 'manual' })
return
}
const target = positions[event.destination.key]
if (!isHistoryRestore || target === undefined) return
pending = target
event.intercept({ scroll: 'manual' })
})
const restore = () => {
if (pending === null) return
window.scrollTo(0, pending)
pending = null
}
// S View Transition přijde htmx:after:swap až po přechodu. Posun proto patří
// na konec jeho úlohy, aby už nový snímek byl posunutý správně.
document.addEventListener('htmx:before:viewTransition', (event) => {
if (pending === null) return
const task = event.detail.task
event.detail.task = async () => {
await task()
restore()
}
})
document.addEventListener('htmx:after:swap', restore)
}
Prohlížeče bez Navigation API modul vynechává, posun tam obnovuje prohlížeč sám i s popsaným skokem. Modální okna provázaná s historií jsou ve třetím dílu.
Lazy loading
Vyplatí se u obsahu, který je velký nebo náročný na přípravu a většina lidí ho neotevře, například graf vývoje ceny, historie objednávek nebo náhled dokumentu ve sbaleném oddílu:
<details>
<summary>Vývoj ceny</summary>
<div hx-get="{{ path('app_product_price_history', {id: product.id}) }}"
hx-trigger="toggle once from:'closest details'"
hx-swap="innerHTML"></div>
</details>
#[Route('/produkt/{id}/vyvoj-ceny', name: 'app_product_price_history', requirements: ['id' => '\d+'], methods: ['GET'])]
public function priceHistory(#[MapEntity(id: 'id')] Product $product): Response
{
$response = $this->render('product/_price_history.html.twig', ['product' => $product]);
$response->setPublic();
$response->setMaxAge(3600);
$response->headers->set('X-Robots-Tag', 'noindex');
return $response;
}
- Víceslovný selektor v
hx-triggermusí být v uvozovkách. Závorky htmx neumí a chyba při parsování zastaví zpracování zbytku stránky. - Další užitečné triggery:
load(hned po vložení),revealed(když se prvek dostane do viewportu),intersects prahem. - Když hodnota z adresy určuje šablonu nebo soubor, omezte ji přes
requirements. - Rezervujte místo pro obsah, ať se stránka po načtení neposune.
- Fragment bez osobních údajů může být veřejně cachovaný.
JavaScript pro nový obsah
- Widgety inicializujte v
htmx.onLoad(callback). Běží při prvním načtení i po každém swapu, včetně kroku zpět. Inicializace musí být opakovatelná. htmx:after:swapse spouští na prvku, který request poslal, ne na targetu. Na inicializaci nového obsahu se nehodí.- Těžké knihovny (zvýrazňování kódu, mapy, grafy) načítejte přes
import(), až když je na stránce jejich prvek. V hlavním bundlu zůstane jen podmínka:
function highlightCode(root = document) {
if (!root.querySelector?.('pre code:not([data-highlighted])')) return
import('./js/code-highlight.js').then(({ default: highlight }) => highlight(root))
}
highlightCode()
htmx.onLoad(highlightCode)
SEO
htmx je pro vyhledávače neviditelné, pokud platí jediné pravidlo: každá adresa vrátí bez JavaScriptu celou stránku. Robot neposílá HX-Request, takže dostane totéž co uživatel při prvním načtení.
curl -s https://example.com/sluzby | grep -E '<title>|rel="canonical"|<h1'
curl -sI https://example.com/neexistuje | head -1
- Odkazy jsou
<a href>. Robot neprocházíhx-getna tlačítku ani na<div>. - Co se má najít, je v prvním HTML. Robot neklikne, neposouvá a neotevře oddíl. Přes lazy loading načítejte jen doplňky. Text, který má být k nalezení, nechte v HTML, i když je ve sbaleném
<details>. Řada AI crawlerů JavaScript vůbec nespouští. - Titulek, description,
canonicalahreflangjsou v odpovědi každé adresy,hx-headje pak vymění i uživateli. - Skutečné status kódy. 404 musí být 404, přesun 301. Error page se stavem 200 by se dostala do indexu.
- Fragmenty nepatří do indexu. Vlastní routy dostanou
X-Robots-Tag: noindexa nejsou v sitemapě. Neblokujte je vrobots.txt, robot by hlavičku nepřečetl. - Core Web Vitals stojí hlavně na prvním načtení, ne na přechodech. Hlídejte velikost bundle a rychlost odpovědi serveru, protože přes něj jdou prefetch i krok zpět.
Stránkování a filtry z pohledu SEO popisuje díl 2.
Měření
Přechod přes htmx není načtení stránky, takže analytika zobrazení sama nepočítá.
- Vlastní pageview odešlete po
htmx:after:swap, jen když se změnila adresa. Otevřené okno ani odeslaný formulář se pak nepočítají. - Titulek je v tu chvíli nový,
hx-headho sloučil už při odpovědi. Události po zápisu do historie přicházejí ještě před swapem, kdy stránka ukazuje starý obsah. - Google Tag Manager zvládne přechody přes trigger History Change. S vlastním odesláním navíc máte dvě zobrazení.
- Krok zpět se počítá jako zobrazení.
- Skript analytiky označte
hx-preserve="true".
Testy a nasazení
- Před aktualizací htmx vyzkoušejte přechod a odeslání formuláře i ve starším prohlížeči a projděte výskyty
htmx:v šablonách. - V end-to-end testech čekejte na
htmx:finally:requesta listener navěste ve stejném skriptu před klikem. Čekání na zmizení třídy.htmx-requesthned po kliknutí může skončit dřív, než se třída objeví. - Krok zpět testujte v Chromu i ve Firefoxu, jde tam jiným kódem. Zkoušejte s View Transitions i s
prefers-reduced-motion. - Přechody zkoušejte i ve WebKitu. Chyba morphu tam nemusí nic vypsat, poznáte ji podle toho, že po kliknutí zůstane část staré stránky.
- Error pages testujte na
proda jednou i výpadek bez vaší šablony, například 502 z proxy.
Kontrolní seznam
hx-headahx-preloadimportované za jádrem, skripty v<head>, jednorázové shx-preserve="true"<body>shx-boost:inheriteda morphem, nebo#appshx-head="merge"ahx-history-elt- Hlavní kontejner s
idpodle routy, vnitřníidse mezi stránkami neopakují hx-boost="false"na odhlášení, přihlášení, administraci a jiné layouty- Akce, které něco mění (košík, smazání, hlasování…), přes POST, jinak
hx-preload="none" <html lang>po přepnutí jazyka odpovídá stránceAuthenticationEntryPointvrací u htmxHX-Redirect, pokud má login jiný layout nebo jsou za přihlášením fragmenty- Error pages s celým layoutem, u
#apppojistka na prázdný výběr - Zkrácené View Transitions, vypnuté při
prefers-reduced-motion, žádný globálníscroll-behavior: smooth - Modul pro scroll restoration registrovaný před spuštěním htmx
- Stav, který má přežít Zpět, je v URL
- Widgety v
htmx.onLoad, opakovatelná inicializace, klientské příznaky vmorphIgnore - SEO:
curlvrací celou stránku, odkazy jsou<a href>, hlavní obsah v prvním HTML, fragmenty snoindex - Pageview po
htmx:after:swap, ne po změně historie - Krok zpět otestovaný v Chromu i ve Firefoxu, přechody i ve WebKitu, error pages na
prod