shshadcn-htmx

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.json

2. Use it

components/ui/landmarks.tsx
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
components/ui/landmarks.tsx
/** @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

templates/components/landmarks.html
{% 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
templates/components/landmarks.html
{# 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

templates/components/landmarks.tmpl
{{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
templates/components/landmarks.tmpl
{{/*
  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

lib/my_app_web/components/landmarks.ex
<.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
lib/my_app_web/components/landmarks.ex
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

index.html
<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
index.html
<!--
  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>&lt;main&gt;</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".

Acme Console

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

PropTypeDefaultDescription
<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
ariaLabelstring
Accessible name. Required for region; recommended for navigation/complementary/search once there is more than one of that type.MDNaria-label
ariaLabelledbystring
Id of a visible heading that names the landmark. Preferred over ariaLabel when a title is on screen.MDNaria-labelledby
classstring
Extra Tailwind classes appended to the root element.