Components
Landmarks
An accessible page-shell built from the native HTML landmark elements — header, nav, search, main, aside, section, footer. Each wrapper exposes the matching ARIA landmark role for free, so assistive-tech users can jump between the major regions of the page. Pure structure — no JS. Follows the WAI-ARIA APG Landmarks practice.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/landmarks.json2. Use it
import {
Banner, NavLandmark, SearchLandmark, MainLandmark,
Complementary, RegionLandmark, ContentInfo,
} from "@/components/ui/landmarks"
<Banner>
<h1>Acme Console</h1>
<SearchLandmark ariaLabel="Site">
<form action="/search"><input type="search" name="q" /></form>
</SearchLandmark>
</Banner>
<NavLandmark ariaLabel="Primary">…</NavLandmark>
<MainLandmark>
<h1>Overview</h1>
<RegionLandmark ariaLabel="Usage this month">…</RegionLandmark>
</MainLandmark>
<Complementary ariaLabel="Related">…</Complementary>
<ContentInfo>© 2026 Acme</ContentInfo>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren, Child } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Landmarks — shadcn-htmx, htmx v4 + Tailwind v4.
//
// An accessible page-shell built from the native HTML landmark elements.
// Pure structure — no JS. Each subcomponent is a thin semantic wrapper
// around the native element that implicitly exposes the matching ARIA
// landmark role, so assistive-tech users can jump between the major
// regions of the page.
//
// Native element → implicit landmark role
// <header> (in body context) → banner
// <nav> → navigation
// <search> → search
// <main> → main
// <aside> → complementary
// <section> (when labelled) → region
// <form> (when labelled) → form
// <footer> (in body context) → contentinfo
//
// Labelling rules (when to pass an aria-label / aria-labelledby) come
// straight from the APG. Several landmark roles must (region) or should
// (navigation, complementary, search) carry a unique label so multiple
// landmarks of the same type are distinguishable.
//
// APG (read for the per-role design patterns + labelling rules):
// repos/aria-practices/content/patterns/landmarks/examples/banner.html
// repos/aria-practices/content/patterns/landmarks/examples/navigation.html
// repos/aria-practices/content/patterns/landmarks/examples/main.html
// repos/aria-practices/content/patterns/landmarks/examples/complementary.html
// repos/aria-practices/content/patterns/landmarks/examples/region.html
// repos/aria-practices/content/patterns/landmarks/examples/search.html
// repos/aria-practices/content/patterns/landmarks/examples/contentinfo.html
// MDN (the native <search> element defines a search landmark — no role=search needed):
// repos/mdn/files/en-us/web/html/reference/elements/search/index.md:20-22
type LandmarkProps = PropsWithChildren<
{
class?: ClassValue
id?: string
// The accessible name for this landmark. Where the APG calls for one
// (navigation, complementary, search when there is more than one; and
// always for region) pass either ariaLabel or ariaLabelledby.
ariaLabel?: string
ariaLabelledby?: string
} & Record<string, any>
>
// <header> in body context = banner landmark. Top-level only: a <header>
// nested inside article/aside/main/nav/section is just a sectioning header,
// not a banner. There should be one banner per page.
// APG: repos/aria-practices/content/patterns/landmarks/examples/banner.html:52-57,67-76
export function Banner(props: LandmarkProps) {
const { class: className, children, ariaLabel, ariaLabelledby, ...rest } = props
return (
<header
data-slot="landmark-banner"
class={cn("border-b bg-card px-4 py-3 text-card-foreground", className)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{...rest}
>
{children}
</header>
)
}
// <nav> = navigation landmark. If a page has more than one navigation
// landmark, each should have a unique label; with only one, a label is
// optional (but recommended once there are multiple navs in a shell).
// APG: repos/aria-practices/content/patterns/landmarks/examples/navigation.html:51-52,61
export function NavLandmark(props: LandmarkProps) {
const { class: className, children, ariaLabel, ariaLabelledby, ...rest } = props
return (
<nav
data-slot="landmark-navigation"
class={cn("text-sm", className)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{...rest}
>
{children}
</nav>
)
}
// <search> = search landmark (native element). Removes the need for
// role="search" on the inner <form>. Use the search landmark instead of
// the form landmark when the form performs a search/filter. If there is
// more than one search landmark, each should have a unique label.
// MDN: repos/mdn/files/en-us/web/html/reference/elements/search/index.md:20-22,32-43
// APG: repos/aria-practices/content/patterns/landmarks/examples/search.html:54-55,64-66
export function SearchLandmark(props: LandmarkProps) {
const { class: className, children, ariaLabel, ariaLabelledby, ...rest } = props
return (
<search
data-slot="landmark-search"
class={cn("", className)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{...rest}
>
{children}
</search>
)
}
// <main> = main landmark. Exactly one per page, and it should be a
// top-level landmark (not nested in another landmark).
// APG: repos/aria-practices/content/patterns/landmarks/examples/main.html:51-52
export function MainLandmark(props: LandmarkProps) {
const { class: className, children, ariaLabel, ariaLabelledby, ...rest } = props
return (
<main
data-slot="landmark-main"
class={cn("min-w-0 flex-1", className)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{...rest}
>
{children}
</main>
)
}
// <aside> = complementary landmark. Supporting content at a similar level
// to the main content; should be a top-level landmark. If the content
// isn't related to the main content, use a region instead. With more than
// one complementary landmark, each should have a unique label.
// APG: repos/aria-practices/content/patterns/landmarks/examples/complementary.html:52-54
export function Complementary(props: LandmarkProps) {
const { class: className, children, ariaLabel, ariaLabelledby, ...rest } = props
return (
<aside
data-slot="landmark-complementary"
class={cn(
"rounded-lg border bg-card p-4 text-sm text-card-foreground",
className,
)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{...rest}
>
{children}
</aside>
)
}
// <section> = region landmark, but ONLY when it has an accessible name.
// A bare <section> exposes NO landmark role; a region landmark MUST have a
// label — so always pass ariaLabel or ariaLabelledby here. Used to name
// content that no other (named) landmark appropriately describes.
// APG: repos/aria-practices/content/patterns/landmarks/examples/region.html:52-54,64,101-121
export function RegionLandmark(props: LandmarkProps) {
const { class: className, children, ariaLabel, ariaLabelledby, ...rest } = props
return (
<section
data-slot="landmark-region"
class={cn("rounded-lg border bg-card p-4 text-card-foreground", className)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{...rest}
>
{children}
</section>
)
}
// <form> = form landmark, but ONLY when it has an accessible name. A bare
// <form> exposes NO landmark role; the form landmark appears once you pass
// ariaLabel or ariaLabelledby — exactly like region. Give each form
// landmark a unique label. Use SearchLandmark (<search>) instead when the
// form performs a search. The id/...rest passthrough means action/method/
// hx-* forward straight through to the <form>.
// MDN: repos/mdn/files/en-us/web/accessibility/aria/reference/roles/form_role/index.md:12,31,33,101
// APG: repos/aria-practices/content/patterns/landmarks/examples/form.html:79,84,85,86
export function FormLandmark(props: LandmarkProps) {
const { class: className, children, ariaLabel, ariaLabelledby, ...rest } = props
return (
<form
data-slot="landmark-form"
class={cn("rounded-lg border bg-card p-4 text-card-foreground", className)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{...rest}
>
{children}
</form>
)
}
// <footer> in body context = contentinfo landmark. Top-level only: a
// <footer> nested inside article/aside/main/nav/section is just a
// sectioning footer. One contentinfo per page.
// APG: repos/aria-practices/content/patterns/landmarks/examples/contentinfo.html:51-56,66-74
export function ContentInfo(props: LandmarkProps) {
const { class: className, children, ariaLabel, ariaLabelledby, ...rest } = props
return (
<footer
data-slot="landmark-contentinfo"
class={cn(
"border-t bg-card px-4 py-3 text-sm text-muted-foreground",
className,
)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{...rest}
>
{children}
</footer>
)
}
// Convenience shell — a tasteful demo skeleton wiring the landmarks into a
// holy-grail layout (banner on top, a row of [navigation | main |
// complementary], contentinfo at the bottom). Compose the subcomponents
// directly when you need a different arrangement.
export function PageShell(
props: PropsWithChildren<{ class?: ClassValue; children?: Child }>,
) {
return (
<div
data-slot="landmark-shell"
class={cn("flex min-h-0 flex-col overflow-hidden rounded-lg border", props.class)}
>
{props.children}
</div>
)
}
1. Save the file
Copy landmarks.html into templates/components/.
2. Use it
{% from "components/landmarks.html" import
banner_open, banner_close, nav_open, nav_close,
search_open, search_close, main_open, main_close,
complementary_open, complementary_close,
region_open, region_close, contentinfo_open, contentinfo_close %}
{{ banner_open() }}<h1>Acme Console</h1>{{ banner_close() }}
{{ nav_open(aria_label="Primary") }}…{{ nav_close() }}
{{ main_open() }}
{{ region_open(aria_label="Usage this month") }}…{{ region_close() }}
{{ main_close() }}
{{ complementary_open(aria_label="Related") }}…{{ complementary_close() }}
{{ contentinfo_open() }}© 2026 Acme{{ contentinfo_close() }}View source
{# Landmarks macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/landmarks.tsx.
An accessible page-shell built from the native HTML landmark elements.
Each native element exposes the matching ARIA landmark role implicitly:
<header> → banner <aside> → complementary
<nav> → navigation <section> → region (only when labelled)
<search> → search <form> → form (only when labelled)
<main> → main <footer> → contentinfo
Labelling rules from the APG (pass aria_label / aria_labelledby where the
practice calls for one — always for region; once there is more than one
navigation/complementary/search landmark each needs a unique label).
repos/aria-practices/content/patterns/landmarks/examples/*.html
The native <search> element defines a search landmark (no role=search):
repos/mdn/files/en-us/web/html/reference/elements/search/index.md:20-22
Usage:
{% from "components/landmarks.html" import
banner_open, banner_close, nav_open, nav_close,
search_open, search_close, main_open, main_close,
complementary_open, complementary_close,
region_open, region_close, form_open, form_close,
contentinfo_open, contentinfo_close %}
{{ banner_open() }} … {{ banner_close() }}
{{ nav_open(aria_label="Primary") }} … {{ nav_close() }}
{{ search_open(aria_label="Site") }}<form …>…</form>{{ search_close() }}
{{ main_open() }} … {{ main_close() }}
{{ complementary_open(aria_label="Related") }} … {{ complementary_close() }}
{{ region_open(aria_label="Stats") }} … {{ region_close() }}
{{ form_open(aria_label="Contact") }}<form-controls…>{{ form_close() }}
{{ contentinfo_open() }} … {{ contentinfo_close() }}
#}
{% macro _attrs(extra) -%}
{%- for k, v in extra.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
{%- endmacro %}
{% macro _label(aria_label, aria_labelledby) -%}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif -%}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif -%}
{%- endmacro %}
{# banner — <header> in body context. Top-level only; one per page. #}
{% macro banner_open(aria_label=none, aria_labelledby=none, extra_class="", **attrs) -%}
<header data-slot="landmark-banner"{{ _label(aria_label, aria_labelledby) }} class="border-b bg-card px-4 py-3 text-card-foreground {{ extra_class }}"{{ _attrs(attrs) }}>
{%- endmacro %}
{% macro banner_close() %}</header>{% endmacro %}
{# navigation — <nav>. Each nav should have a unique label when >1 on a page. #}
{% macro nav_open(aria_label=none, aria_labelledby=none, extra_class="", **attrs) -%}
<nav data-slot="landmark-navigation"{{ _label(aria_label, aria_labelledby) }} class="text-sm {{ extra_class }}"{{ _attrs(attrs) }}>
{%- endmacro %}
{% macro nav_close() %}</nav>{% endmacro %}
{# search — native <search> element. Wrap a <form>. Defines a search landmark
so no role=search on the form is needed. #}
{% macro search_open(aria_label=none, aria_labelledby=none, extra_class="", **attrs) -%}
<search data-slot="landmark-search"{{ _label(aria_label, aria_labelledby) }} class="{{ extra_class }}"{{ _attrs(attrs) }}>
{%- endmacro %}
{% macro search_close() %}</search>{% endmacro %}
{# main — <main>. Exactly one per page; top-level. #}
{% macro main_open(aria_label=none, aria_labelledby=none, extra_class="", **attrs) -%}
<main data-slot="landmark-main"{{ _label(aria_label, aria_labelledby) }} class="min-w-0 flex-1 {{ extra_class }}"{{ _attrs(attrs) }}>
{%- endmacro %}
{% macro main_close() %}</main>{% endmacro %}
{# complementary — <aside>. Top-level; unique label when >1. #}
{% macro complementary_open(aria_label=none, aria_labelledby=none, extra_class="", **attrs) -%}
<aside data-slot="landmark-complementary"{{ _label(aria_label, aria_labelledby) }} class="rounded-lg border bg-card p-4 text-sm text-card-foreground {{ extra_class }}"{{ _attrs(attrs) }}>
{%- endmacro %}
{% macro complementary_close() %}</aside>{% endmacro %}
{# region — <section> WITH a label (a region landmark must be named). #}
{% macro region_open(aria_label=none, aria_labelledby=none, extra_class="", **attrs) -%}
<section data-slot="landmark-region"{{ _label(aria_label, aria_labelledby) }} class="rounded-lg border bg-card p-4 text-card-foreground {{ extra_class }}"{{ _attrs(attrs) }}>
{%- endmacro %}
{% macro region_close() %}</section>{% endmacro %}
{# form — <form> WITH a label (a form landmark must be named, like region).
Use search_open instead when the form performs a search. action/method/
hx-* pass straight through via **attrs.
MDN repos/mdn/files/en-us/web/accessibility/aria/reference/roles/form_role/index.md:12,31,33,101 #}
{% macro form_open(aria_label=none, aria_labelledby=none, extra_class="", **attrs) -%}
<form data-slot="landmark-form"{{ _label(aria_label, aria_labelledby) }} class="rounded-lg border bg-card p-4 text-card-foreground {{ extra_class }}"{{ _attrs(attrs) }}>
{%- endmacro %}
{% macro form_close() %}</form>{% endmacro %}
{# contentinfo — <footer> in body context. Top-level only; one per page. #}
{% macro contentinfo_open(aria_label=none, aria_labelledby=none, extra_class="", **attrs) -%}
<footer data-slot="landmark-contentinfo"{{ _label(aria_label, aria_labelledby) }} class="border-t bg-card px-4 py-3 text-sm text-muted-foreground {{ extra_class }}"{{ _attrs(attrs) }}>
{%- endmacro %}
{% macro contentinfo_close() %}</footer>{% endmacro %}
1. Save the file
Add landmarks.tmpl alongside your other templates.
2. Use it
{{template "landmark_banner" (dict
"Body" (htmlSafe `<h1>Acme Console</h1>`)
)}}
{{template "landmark_nav" (dict
"AriaLabel" "Primary" "Body" (htmlSafe `<ul>…</ul>`)
)}}
{{template "landmark_main" (dict
"Body" (htmlSafe `<h1>Overview</h1>`)
)}}
{{template "landmark_region" (dict
"AriaLabel" "Usage this month" "Body" (htmlSafe `…`)
)}}
{{template "landmark_contentinfo" (dict
"Body" (htmlSafe `© 2026 Acme`)
)}}View source
{{/*
Landmarks templates — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/landmarks.tsx.
An accessible page-shell built from the native HTML landmark elements.
Each native element exposes the matching ARIA landmark role implicitly:
<header> -> banner <aside> -> complementary
<nav> -> navigation <section> -> region (only when labelled)
<search> -> search <form> -> form (only when labelled)
<main> -> main <footer> -> contentinfo
Labelling rules from the APG (pass AriaLabel / AriaLabelledby where the
practice calls for one — always for region; once there is more than one
navigation/complementary/search landmark, each needs a unique label).
repos/aria-practices/content/patterns/landmarks/examples/*.html
The native <search> element defines a search landmark (no role=search):
repos/mdn/files/en-us/web/html/reference/elements/search/index.md:20-22
Each named template takes a (dict ...) and renders one landmark. Pass the
inner markup as a pre-rendered template.HTML via (htmlSafe ...):
{{template "landmark_main" (dict
"Body" (htmlSafe `<h1>Dashboard</h1>…`)
)}}
{{template "landmark_nav" (dict
"AriaLabel" "Primary"
"Body" (htmlSafe `<ul>…</ul>`)
)}}
*/}}
{{/* banner — <header> in body context. Top-level only; one per page. */}}
{{define "landmark_banner"}}
<header data-slot="landmark-banner"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="border-b bg-card px-4 py-3 text-card-foreground{{with .Class}} {{.}}{{end}}">{{.Body}}</header>
{{end}}
{{/* navigation — <nav>. Each nav should have a unique label when >1 on a page. */}}
{{define "landmark_nav"}}
<nav data-slot="landmark-navigation"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="text-sm{{with .Class}} {{.}}{{end}}">{{.Body}}</nav>
{{end}}
{{/* search — native <search> element. Wrap a <form>; defines a search
landmark so no role=search on the form is needed. */}}
{{define "landmark_search"}}
<search data-slot="landmark-search"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="{{with .Class}}{{.}}{{end}}">{{.Body}}</search>
{{end}}
{{/* main — <main>. Exactly one per page; top-level. */}}
{{define "landmark_main"}}
<main data-slot="landmark-main"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="min-w-0 flex-1{{with .Class}} {{.}}{{end}}">{{.Body}}</main>
{{end}}
{{/* complementary — <aside>. Top-level; unique label when >1. */}}
{{define "landmark_complementary"}}
<aside data-slot="landmark-complementary"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="rounded-lg border bg-card p-4 text-sm text-card-foreground{{with .Class}} {{.}}{{end}}">{{.Body}}</aside>
{{end}}
{{/* region — <section> WITH a label (a region landmark must be named). */}}
{{define "landmark_region"}}
<section data-slot="landmark-region"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="rounded-lg border bg-card p-4 text-card-foreground{{with .Class}} {{.}}{{end}}">{{.Body}}</section>
{{end}}
{{/* form — <form> WITH a label (a form landmark must be named, like region).
Use landmark_search instead when the form performs a search.
MDN repos/mdn/files/en-us/web/accessibility/aria/reference/roles/form_role/index.md:12,31,33,101 */}}
{{define "landmark_form"}}
<form data-slot="landmark-form"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="rounded-lg border bg-card p-4 text-card-foreground{{with .Class}} {{.}}{{end}}">{{.Body}}</form>
{{end}}
{{/* contentinfo — <footer> in body context. Top-level only; one per page. */}}
{{define "landmark_contentinfo"}}
<footer data-slot="landmark-contentinfo"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="border-t bg-card px-4 py-3 text-sm text-muted-foreground{{with .Class}} {{.}}{{end}}">{{.Body}}</footer>
{{end}}
1. Save the file
Drop landmarks.ex into lib/my_app_web/components/.
2. Use it
<.banner>
<h1>Acme Console</h1>
</.banner>
<.nav_landmark aria-label="Primary">…</.nav_landmark>
<.main_landmark>
<h1>Overview</h1>
<.region_landmark aria-label="Usage this month">…</.region_landmark>
</.main_landmark>
<.complementary aria-label="Related">…</.complementary>
<.content_info>© 2026 Acme</.content_info>View source
defmodule ShadcnHtmx.Components.Landmarks do
@moduledoc """
Landmarks — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/landmarks.tsx. An accessible page-shell built from the
native HTML landmark elements. Each native element exposes the matching
ARIA landmark role implicitly:
<header> -> banner <aside> -> complementary
<nav> -> navigation <section> -> region (only when labelled)
<search> -> search <form> -> form (only when labelled)
<main> -> main <footer> -> contentinfo
Labelling rules from the APG (pass `aria-label` / `aria-labelledby` where the
practice calls for one — always for region; once there is more than one
navigation/complementary/search landmark, each needs a unique label).
repos/aria-practices/content/patterns/landmarks/examples/*.html
The native `<search>` element defines a search landmark (no `role=search`):
repos/mdn/files/en-us/web/html/reference/elements/search/index.md:20-22
## Examples
<.banner>
<h1 class="font-semibold">Acme Console</h1>
</.banner>
<.nav_landmark aria-label="Primary">
<ul>…</ul>
</.nav_landmark>
<.search_landmark aria-label="Site">
<form action="/search">
<label for="q">Search</label>
<input id="q" type="search" name="q" />
</form>
</.search_landmark>
<.main_landmark>
<h1>Dashboard</h1>
</.main_landmark>
<.complementary aria-label="Related">…</.complementary>
<.region_landmark aria-label="Usage this month">…</.region_landmark>
<.form_landmark aria-label="Contact">
<form action="/contact" method="post">…</form>
</.form_landmark>
<.content_info>© 2026 Acme</.content_info>
"""
use Phoenix.Component
# banner — <header> in body context. Top-level only; one per page.
attr :class, :string, default: nil
attr :rest, :global, include: ~w(aria-label aria-labelledby)
slot :inner_block, required: true
def banner(assigns) do
~H"""
<header
data-slot="landmark-banner"
class={["border-b bg-card px-4 py-3 text-card-foreground", @class]}
{@rest}
>
{render_slot(@inner_block)}
</header>
"""
end
# navigation — <nav>. Each nav should have a unique label when >1 on a page.
attr :class, :string, default: nil
attr :rest, :global, include: ~w(aria-label aria-labelledby)
slot :inner_block, required: true
def nav_landmark(assigns) do
~H"""
<nav data-slot="landmark-navigation" class={["text-sm", @class]} {@rest}>
{render_slot(@inner_block)}
</nav>
"""
end
# search — native <search> element. Wrap a <form>; defines a search landmark
# so no role=search on the form is needed.
attr :class, :string, default: nil
attr :rest, :global, include: ~w(aria-label aria-labelledby)
slot :inner_block, required: true
def search_landmark(assigns) do
~H"""
<search data-slot="landmark-search" class={[@class]} {@rest}>
{render_slot(@inner_block)}
</search>
"""
end
# main — <main>. Exactly one per page; top-level.
attr :class, :string, default: nil
attr :rest, :global, include: ~w(aria-label aria-labelledby)
slot :inner_block, required: true
def main_landmark(assigns) do
~H"""
<main data-slot="landmark-main" class={["min-w-0 flex-1", @class]} {@rest}>
{render_slot(@inner_block)}
</main>
"""
end
# complementary — <aside>. Top-level; unique label when >1.
attr :class, :string, default: nil
attr :rest, :global, include: ~w(aria-label aria-labelledby)
slot :inner_block, required: true
def complementary(assigns) do
~H"""
<aside
data-slot="landmark-complementary"
class={["rounded-lg border bg-card p-4 text-sm text-card-foreground", @class]}
{@rest}
>
{render_slot(@inner_block)}
</aside>
"""
end
# region — <section> WITH a label (a region landmark must be named).
attr :class, :string, default: nil
attr :rest, :global, include: ~w(aria-label aria-labelledby)
slot :inner_block, required: true
def region_landmark(assigns) do
~H"""
<section
data-slot="landmark-region"
class={["rounded-lg border bg-card p-4 text-card-foreground", @class]}
{@rest}
>
{render_slot(@inner_block)}
</section>
"""
end
# form — <form> WITH a label (a form landmark must be named, like region).
# Use search_landmark instead when the form performs a search. action/method
# and hx-* forward through :global onto the native <form>.
# MDN repos/mdn/files/en-us/web/accessibility/aria/reference/roles/form_role/index.md:12,31,33,101
attr :class, :string, default: nil
attr :rest, :global, include: ~w(aria-label aria-labelledby action method)
slot :inner_block, required: true
def form_landmark(assigns) do
~H"""
<form
data-slot="landmark-form"
class={["rounded-lg border bg-card p-4 text-card-foreground", @class]}
{@rest}
>
{render_slot(@inner_block)}
</form>
"""
end
# contentinfo — <footer> in body context. Top-level only; one per page.
attr :class, :string, default: nil
attr :rest, :global, include: ~w(aria-label aria-labelledby)
slot :inner_block, required: true
def content_info(assigns) do
~H"""
<footer
data-slot="landmark-contentinfo"
class={["border-t bg-card px-4 py-3 text-sm text-muted-foreground", @class]}
{@rest}
>
{render_slot(@inner_block)}
</footer>
"""
end
end
1. Save the file
Native elements + Tailwind utilities only. No script.
2. Use it
<header data-slot="landmark-banner">…</header>
<nav data-slot="landmark-navigation" aria-label="Primary">…</nav>
<main data-slot="landmark-main">
<section data-slot="landmark-region" aria-labelledby="t">…</section>
</main>
<aside data-slot="landmark-complementary" aria-label="Related">…</aside>
<footer data-slot="landmark-contentinfo">…</footer>View source
<!--
shadcn-htmx — raw HTML landmarks page-shell.
Mirrors registry/ui/landmarks.tsx. One self-contained, correctly
landmarked page shell built from native HTML elements — no JS, no special
CSS, just Tailwind utilities + theme tokens.
Native element -> implicit ARIA landmark role:
<header> -> banner <aside> -> complementary
<nav> -> navigation <section> -> region (only when labelled)
<search> -> search <form> -> form (only when labelled)
<main> -> main <footer> -> contentinfo
Labelling rules (APG):
- <main> and the body-level <header>/<footer> need no label (one each).
- Each <nav> carries a unique aria-label so AT can tell them apart.
- <search> wraps the <form>; the element itself is the search landmark
(no role=search needed). See MDN <search>.
- <aside> (complementary) is labelled when there is more than one.
- <section> is ONLY a region landmark when it has an accessible name
(aria-label / aria-labelledby) — a bare <section> exposes no role.
- <form> is ONLY a form landmark when it has an accessible name
(aria-label / aria-labelledby / title); use <search> for search forms.
MDN: repos/mdn/files/en-us/web/accessibility/aria/reference/roles/form_role/index.md:12,31,33,101
data-slot="landmark-<role>" hangs off each root element.
-->
<div data-slot="landmark-shell" class="flex min-h-0 flex-col overflow-hidden rounded-lg border">
<!-- banner: top-level <header> -->
<header data-slot="landmark-banner" class="flex items-center justify-between gap-4 border-b bg-card px-4 py-3 text-card-foreground">
<span class="font-semibold tracking-tight">Acme Console</span>
<!-- search landmark: the native <search> element wraps the form -->
<search data-slot="landmark-search" aria-label="Site" class="hidden sm:block">
<form action="/search" role="none" class="flex items-center gap-2">
<label for="lm-q" class="sr-only">Search the site</label>
<input id="lm-q" type="search" name="q" placeholder="Search…"
class="h-8 w-48 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" />
<button type="submit" class="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90">Go</button>
</form>
</search>
</header>
<div class="flex flex-1 flex-col gap-4 bg-background p-4 md:flex-row">
<!-- navigation: labelled so it is distinguishable from other navs -->
<nav data-slot="landmark-navigation" aria-label="Primary" class="shrink-0 text-sm md:w-44">
<ul class="space-y-1">
<li><a href="#" aria-current="page" class="block rounded-md bg-accent px-2 py-1.5 font-medium text-accent-foreground">Overview</a></li>
<li><a href="#" class="block rounded-md px-2 py-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground">Reports</a></li>
<li><a href="#" class="block rounded-md px-2 py-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground">Settings</a></li>
</ul>
</nav>
<!-- main: exactly one per page, top-level -->
<main data-slot="landmark-main" class="min-w-0 flex-1 space-y-4">
<h1 class="text-lg font-semibold tracking-tight">Overview</h1>
<p class="text-sm text-muted-foreground">
The primary content of the page. There is exactly one
<code><main></code> landmark per document.
</p>
<!-- region: a labelled <section> is a region landmark -->
<section data-slot="landmark-region" aria-labelledby="lm-usage-title" class="rounded-lg border bg-card p-4 text-card-foreground">
<h2 id="lm-usage-title" class="text-sm font-semibold">Usage this month</h2>
<p class="mt-1 text-sm text-muted-foreground">42,318 requests · 7.1 GB transferred</p>
</section>
<!-- form: a labelled <form> is a form landmark (only when named).
Use <search> instead when the form performs a search. -->
<form data-slot="landmark-form" aria-labelledby="lm-contact-title" action="/contact" method="post" class="rounded-lg border bg-card p-4 text-card-foreground">
<h2 id="lm-contact-title" class="text-sm font-semibold">Contact support</h2>
<label for="lm-msg" class="mt-2 block text-sm text-muted-foreground">Message</label>
<textarea id="lm-msg" name="message" rows="3"
class="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"></textarea>
<button type="submit" class="mt-2 inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90">Send</button>
</form>
</main>
<!-- complementary: top-level supporting content, labelled -->
<aside data-slot="landmark-complementary" aria-label="Related" class="shrink-0 rounded-lg border bg-card p-4 text-sm text-card-foreground md:w-56">
<h2 class="font-semibold">Related</h2>
<ul class="mt-2 space-y-1 text-muted-foreground">
<li><a href="#" class="underline-offset-4 hover:underline">Billing history</a></li>
<li><a href="#" class="underline-offset-4 hover:underline">API keys</a></li>
</ul>
</aside>
</div>
<!-- contentinfo: top-level <footer> -->
<footer data-slot="landmark-contentinfo" class="border-t bg-card px-4 py-3 text-sm text-muted-foreground">
© 2026 Acme, Inc. · <a href="#" class="underline-offset-4 hover:underline">Privacy</a> · <a href="#" class="underline-offset-4 hover:underline">Accessibility</a>
</footer>
</div>
Examples
Labelled page shell
A complete, correctly landmarked shell: banner on top, a row of [navigation | main | complementary], contentinfo at the bottom. The search landmark wraps a form inside the banner.
Each landmark maps to a native element, so the roles come from the platform — not from role= attributes. Per the APG, the single main and the body-level header/footer need no label, but every nav and the search and aside carry a unique aria-label so they are distinguishable. The native <search> element is the search landmark, so the inner form needs no role="search".
Overview
The primary content of the page. Exactly one main landmark per document.
Usage this month
42,318 requests · 7.1 GB transferred
<Banner>
<h1>Acme Console</h1>
<SearchLandmark ariaLabel="Site">
<form action="/search">
<label class="sr-only" for="q">Search the site</label>
<input id="q" type="search" name="q" />
<button type="submit">Go</button>
</form>
</SearchLandmark>
</Banner>
<NavLandmark ariaLabel="Primary">
<ul>
<li><a href="#" aria-current="page">Overview</a></li>
<li><a href="#">Reports</a></li>
</ul>
</NavLandmark>
<MainLandmark>
<h1>Overview</h1>
<RegionLandmark ariaLabelledby="usage">
<h2 id="usage">Usage this month</h2>
<p>42,318 requests</p>
</RegionLandmark>
</MainLandmark>
<Complementary ariaLabel="Related">…</Complementary>
<ContentInfo>© 2026 Acme, Inc.</ContentInfo>{{ banner_open() }}
<h1>Acme Console</h1>
{{ search_open(aria_label="Site") }}
<form action="/search">
<label class="sr-only" for="q">Search the site</label>
<input id="q" type="search" name="q" />
<button type="submit">Go</button>
</form>
{{ search_close() }}
{{ banner_close() }}
{{ nav_open(aria_label="Primary") }}
<ul><li><a href="#" aria-current="page">Overview</a></li></ul>
{{ nav_close() }}
{{ main_open() }}
<h1>Overview</h1>
{{ region_open(aria_labelledby="usage") }}
<h2 id="usage">Usage this month</h2>
{{ region_close() }}
{{ main_close() }}
{{ complementary_open(aria_label="Related") }}…{{ complementary_close() }}
{{ contentinfo_open() }}© 2026 Acme, Inc.{{ contentinfo_close() }}{{template "landmark_banner" (dict "Body" (htmlSafe `
<h1>Acme Console</h1>
{{template "landmark_search" (dict "AriaLabel" "Site" "Body" (htmlSafe \`<form action="/search">…</form>\`))}}
`))}}
{{template "landmark_nav" (dict "AriaLabel" "Primary" "Body" (htmlSafe `<ul>…</ul>`))}}
{{template "landmark_main" (dict "Body" (htmlSafe `
<h1>Overview</h1>
{{template "landmark_region" (dict "AriaLabelledby" "usage" "Body" (htmlSafe \`<h2 id="usage">Usage this month</h2>\`))}}
`))}}
{{template "landmark_complementary" (dict "AriaLabel" "Related" "Body" (htmlSafe `…`))}}
{{template "landmark_contentinfo" (dict "Body" (htmlSafe `© 2026 Acme, Inc.`))}}<.banner>
<h1>Acme Console</h1>
<.search_landmark aria-label="Site">
<form action="/search">
<label class="sr-only" for="q">Search the site</label>
<input id="q" type="search" name="q" />
<button type="submit">Go</button>
</form>
</.search_landmark>
</.banner>
<.nav_landmark aria-label="Primary">
<ul><li><a href="#" aria-current="page">Overview</a></li></ul>
</.nav_landmark>
<.main_landmark>
<h1>Overview</h1>
<.region_landmark aria-labelledby="usage">
<h2 id="usage">Usage this month</h2>
</.region_landmark>
</.main_landmark>
<.complementary aria-label="Related">…</.complementary>
<.content_info>© 2026 Acme, Inc.</.content_info><div data-slot="landmark-shell" class="flex w-full flex-col overflow-hidden rounded-lg border">
<header data-slot="landmark-banner" class="border-b bg-card px-4 py-3 text-card-foreground flex items-center justify-between gap-4">
<span class="font-semibold tracking-tight">Acme Console</span>
<search data-slot="landmark-search" class="" aria-label="Site">
<form action="/search" class="flex items-center gap-2">
<label for="ex-lm-q" class="sr-only">Search the site</label>
<input id="ex-lm-q" type="search" name="q" placeholder="Search…" class="h-8 w-40 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"/>
<button type="submit" class="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90">Go</button>
</form>
</search>
</header>
<div class="flex flex-col gap-4 bg-background p-4 md:flex-row">
<nav data-slot="landmark-navigation" class="text-sm shrink-0 md:w-40" aria-label="Primary">
<ul class="space-y-1">
<li>
<a href="#" aria-current="page" class="block rounded-md bg-accent px-2 py-1.5 font-medium text-accent-foreground">Overview</a>
</li>
<li>
<a href="#" class="block rounded-md px-2 py-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground">Reports</a>
</li>
<li>
<a href="#" class="block rounded-md px-2 py-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground">Settings</a>
</li>
</ul>
</nav>
<main data-slot="landmark-main" class="min-w-0 flex-1 space-y-4">
<h1 class="text-lg font-semibold tracking-tight">Overview</h1>
<p class="text-sm text-muted-foreground">The primary content of the page. Exactly one main landmark per document.</p>
<section data-slot="landmark-region" class="rounded-lg border bg-card p-4 text-card-foreground" aria-labelledby="ex-lm-usage">
<h2 id="ex-lm-usage" class="text-sm font-semibold">Usage this month</h2>
<p class="mt-1 text-sm text-muted-foreground">42,318 requests · 7.1 GB transferred</p>
</section>
</main>
<aside data-slot="landmark-complementary" class="rounded-lg border bg-card p-4 text-sm text-card-foreground shrink-0 md:w-48" aria-label="Related">
<h2 class="font-semibold">Related</h2>
<ul class="mt-2 space-y-1 text-muted-foreground">
<li>
<a href="#" class="underline-offset-4 hover:underline">Billing history</a>
</li>
<li>
<a href="#" class="underline-offset-4 hover:underline">API keys</a>
</li>
</ul>
</aside>
</div>
<footer data-slot="landmark-contentinfo" class="border-t bg-card px-4 py-3 text-sm text-muted-foreground">
© 2026 Acme, Inc. ·
<a href="#" class="underline-offset-4 hover:underline">Privacy</a>
·
<a href="#" class="underline-offset-4 hover:underline">Accessibility</a>
</footer>
</div>API Reference
Landmarks
| Prop | Type | Default | Description |
|---|---|---|---|
<FormLandmark> | <form> | — | Form landmark. A <form> exposes the form role only when it has an accessible name — always pass ariaLabel or ariaLabelledby (give each a unique label). Use <SearchLandmark> instead when the form performs a search. action/method/hx-* forward through to the native <form>.MDNform role |
<Banner> | <header> | — | Banner landmark — top-level only; one per page. A <header> nested in article/aside/main/nav/section is just a sectioning header.APGBanner landmark |
<NavLandmark> | <nav> | — | Navigation landmark. Give each a unique ariaLabel when a page has more than one nav.APGNavigation landmark |
<SearchLandmark> | <search> | — | Search landmark via the native <search> element wrapping a <form>; no role="search" needed.MDN<search> element |
<MainLandmark> | <main> | — | Main landmark — exactly one per page, top-level.APGMain landmark |
<Complementary> | <aside> | — | Complementary landmark for supporting content; label each when more than one.APGComplementary landmark |
<RegionLandmark> | <section> | — | Region landmark. A bare <section> exposes NO role — always pass ariaLabel or ariaLabelledby.APGRegion landmark |
<ContentInfo> | <footer> | — | Contentinfo landmark (copyright, privacy/accessibility links) — top-level only; one per page.APGContentinfo landmark |
ariaLabel | string | — | Accessible name. Required for region; recommended for navigation/complementary/search once there is more than one of that type.MDNaria-label |
ariaLabelledby | string | — | Id of a visible heading that names the landmark. Preferred over ariaLabel when a title is on screen.MDNaria-labelledby |
class | string | — | Extra Tailwind classes appended to the root element. |