shshadcn-htmx

Components

Label

A native <label> with shadcn polish. The platform already handles click-to-focus and the accessible-name pairing; we only restyle.

Installation

One file per stack. The same shadcn CLI / curl flow as every other component.

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/label.json

2. Use it

components/ui/label.tsx
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"

<Label htmlFor="email">Email</Label>
<Input id="email" type="email" name="email" />
Or copy the source manually
components/ui/label.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Native <label> with shadcn styling. Source of truth:
//   repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/label.tsx
//
// We render a real <label> instead of wrapping Radix Label.Root — the only
// behaviour Radix adds is "click anywhere on the label focuses the linked
// input," which the platform gives us for free via the `for` (htmlFor)
// attribute. So we keep things simple and inherit native semantics.
//
// Wire-up patterns:
//   - Explicit:  <Label htmlFor="email">Email</Label> <Input id="email" />
//   - Implicit:  <Label>Email <Input /></Label>
// Both work; explicit is preferred because it survives the input being moved
// inside a wrapper later (e.g. for layout).

const base =
  "flex items-center gap-2 text-sm leading-none font-medium select-none " +
  // Dim the label when the input inside its group is disabled (data-disabled=true
  // is shadcn's convention for "this wrapper is in a disabled state").
  "group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 " +
  // Dim when a sibling marked .peer carries `disabled` (the input next to us).
  "peer-disabled:cursor-not-allowed peer-disabled:opacity-50"

export function labelClasses(opts?: { class?: ClassValue }): string {
  return cn(base, opts?.class)
}

type LabelProps = PropsWithChildren<{
  htmlFor?: string
  class?: ClassValue
  id?: string
}>

export function Label(props: LabelProps) {
  const { children, htmlFor, class: className, id, ...rest } = props
  return (
    <label
      id={id}
      for={htmlFor}
      class={labelClasses({ class: className })}
      data-slot="label"
      {...rest}
    >
      {children}
    </label>
  )
}

1. Save the file

Copy label.html into your templates/components/ folder.

2. Use it

templates/components/label.html
{% from "components/label.html" import label %}
{% from "components/input.html" import input %}

{{ label("Email", for_="email") }}
{{ input(id="email", type="email", name="email") }}
View source
templates/components/label.html
{# Label macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/label.tsx for Python/Flask/FastAPI/Django/Jinja2.

   Usage (explicit, preferred):
       {% from "components/label.html" import label %}
       {{ label("Email", for_="email") }}
       {{ input(id="email", name="email", type="email") }}

   Implicit wrapping also works — pass the inner HTML via the caller block:
       {% call label_block() %}Email {{ input(...) }}{% endcall %}
   See repos/mdn/files/en-us/web/html/reference/elements/label/. #}

{% macro label(
    text,
    for_=none,
    id=none,
    extra_class="",
    **attrs
) %}
{%- set base -%}
flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50
{%- endset -%}
<label class="{{ base }} {{ extra_class }}"
       {%- if for_ %} for="{{ for_ }}"{% endif %}
       {%- if id %} id="{{ id }}"{% endif %}
       data-slot="label"
       {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ text }}</label>
{% endmacro %}

{# Block form — accepts arbitrary inner HTML (e.g. wrapping an input):

   {% call label_block(for_="email") %}
       Email {{ input(id="email", name="email", type="email") }}
   {% endcall %}                                                       #}
{% macro label_block(for_=none, id=none, extra_class="") %}
{%- set base -%}
flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50
{%- endset -%}
<label class="{{ base }} {{ extra_class }}"
       {%- if for_ %} for="{{ for_ }}"{% endif %}
       {%- if id %} id="{{ id }}"{% endif %}
       data-slot="label"
>{{ caller() }}</label>
{% endmacro %}

1. Save the file

Add label.tmpl alongside button.tmpl in your templates tree.

2. Use it

templates/components/label.tmpl
tpl.ExecuteTemplate(w, "label", map[string]any{
    "Text": "Email", "For": "email",
})
tpl.ExecuteTemplate(w, "input", map[string]any{
    "ID": "email", "Type": "email", "Name": "email",
})
View source
templates/components/label.tmpl
{{/*
  Label template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/label.tsx for Go projects using html/template.

  Usage:

      type LabelArgs struct {
          Text  string
          For   string // the input id this label points at
          ID    string
          Attrs map[string]string
      }

      tpl.ExecuteTemplate(w, "label", LabelArgs{Text: "Email", For: "email"})

  For implicit wrapping (label contains the input), use the "label_block"
  variant and provide an `Inner` field of pre-rendered HTML.
*/}}

{{define "label"}}
{{- $base := "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" -}}
<label class="{{$base}}"
       {{- if .For}} for="{{.For}}"{{end}}
       {{- if .ID}} id="{{.ID}}"{{end}}
       data-slot="label"
       {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Text}}</label>
{{end}}

1. Save the file

Drop label.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/label.ex
<.label for="email">Email</.label>
<.input id="email" type="email" name="email" />
View source
lib/my_app_web/components/label.ex
defmodule ShadcnHtmx.Components.Label do
  @moduledoc """
  Label — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/label.tsx. Native <label> with the `for` attribute does
  the work — clicking the label focuses the input it points at.

  ## Examples

      <.label for="email">Email</.label>
      <.input id="email" name="email" type="email" />

      # Implicit wrapping
      <.label>
        Email <.input name="email" type="email" />
      </.label>
  """

  use Phoenix.Component

  @base "flex items-center gap-2 text-sm leading-none font-medium select-none " <>
          "group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 " <>
          "peer-disabled:cursor-not-allowed peer-disabled:opacity-50"

  attr :for, :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global

  slot :inner_block, required: true

  def label(assigns) do
    assigns = assign(assigns, :base_class, @base)

    ~H"""
    <label
      for={@for}
      class={[@base_class, @class]}
      data-slot="label"
      {@rest}
    >
      {render_slot(@inner_block)}
    </label>
    """
  end
end

1. Save the file

Tailwind v4 is enough; no extra script required for Label.

2. Use it

index.html
<label for="email" data-slot="label"
       class="flex items-center gap-2 text-sm font-medium leading-none select-none">
  Email
</label>
<input id="email" type="email" name="email" class="…" />
View source
index.html
<!--
  shadcn-htmx — raw HTML label snippets.

  Drop these onto any page with Tailwind v4 + the shadcn theme variables.

  BASE (shared by every label):
    flex items-center gap-2 text-sm leading-none font-medium select-none
    group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50
    peer-disabled:cursor-not-allowed peer-disabled:opacity-50
-->

<!-- Explicit (preferred) — for points at the input id -->
<label for="email" data-slot="label"
  class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50">
  Email
</label>
<input id="email" name="email" type="email"
  class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs">

<!-- Implicit — label wraps the input; click anywhere on the label focuses it -->
<label data-slot="label"
  class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50">
  Email
  <input name="email" type="email"
    class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs">
</label>

<!-- Required indicator — visual + an aria-hidden asterisk inside the label -->
<label for="password" data-slot="label"
  class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50">
  Password <span class="text-destructive" aria-hidden="true">*</span>
</label>
<input id="password" name="password" type="password" required aria-required="true"
  class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs">

<!-- Peer-disabled — wrap label+input in a group and add `peer` to the input;
     the label dims itself when the input is disabled -->
<div class="flex flex-col gap-2">
  <input id="locked" name="locked" type="text" disabled value="locked"
         class="peer flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs disabled:opacity-50">
  <label for="locked" data-slot="label"
    class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50">
    Locked field (auto-dims when input is disabled)
  </label>
</div>

Examples

Explicit — for points at the input id

The label's for attribute matches the input's id. Click anywhere on the label and focus jumps to the input.

Explicit pairing is the safest pattern: it survives layout changes (wrapping in extra <div>s, moving the input into a different parent). It also lets screen readers compute the accessible name even when the label and input aren't DOM neighbours. Rule: every form field gets a label.

<Label htmlFor="email">Email</Label>
<Input id="email" type="email" name="email" />
{{ label("Email", for_="email") }}
{{ input(id="email", type="email", name="email") }}
{{template "label" (dict "Text" "Email" "For" "email")}}
{{template "input" (dict "ID" "email" "Type" "email" "Name" "email")}}
<.label for="email">Email</.label>
<.input id="email" type="email" name="email" />
<div class="grid w-full max-w-md gap-2">
  <label for="ex-explicit-email" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Email</label>
  <input type="email" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;.htmx-request]:opacity-70" data-slot="input" id="ex-explicit-email" name="email" placeholder="[email protected]"/>
</div>

Implicit — wrap the input

No for/id needed: the input is a descendant of the label, so the platform pairs them automatically.

Useful when you control the layout and want fewer ids floating around. The catch: assistive tech still reads the label text as the accessible name, so put the label text outside the input (before or after, doesn't matter — implicit pairing works either way).

<Label>
  Email
  <Input type="email" name="email" />
</Label>
{% call label_block() %}
  Email {{ input(type="email", name="email") }}
{% endcall %}
{{/* Implicit form: render the input inside the label HTML manually,
       since Go html/template doesn't have a block syntax like Jinja's caller. */}}
<.label>
  Email <.input type="email" name="email" />
</.label>
<div class="grid w-full max-w-md gap-2">
  <label class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">
    Email
    <input type="email" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;.htmx-request]:opacity-70" data-slot="input" name="email" placeholder="[email protected]"/>
  </label>
</div>

Required — visual + ARIA

Add a visible marker (asterisk) and the required attribute on the input. Optionally aria-required if you can't use the native attribute.

Don't rely on the asterisk alone — set required on the input so the browser blocks submit and announces the state. Hide the visual asterisk from assistive tech with aria-hidden="true" so it isn't read as "Email asterisk Email".

<Label htmlFor="email">
  Email <span class="text-destructive" aria-hidden="true">*</span>
</Label>
<Input id="email" type="email" name="email" required />
{% call label_block(for_="email") %}
  Email <span class="text-destructive" aria-hidden="true">*</span>
{% endcall %}
{{ input(id="email", type="email", name="email", required=true) }}
{{/* Compose the label text in your Go code: */}}
{{template "label" (dict "For" "email" "Text" "Email *")}}
{{template "input" (dict "ID" "email" "Type" "email" "Name" "email" "Required" true)}}
<.label for="email">
  Email <span class="text-destructive" aria-hidden="true">*</span>
</.label>
<.input id="email" type="email" name="email" required />
<div class="grid w-full max-w-md gap-2">
  <label for="ex-required-email" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">
    Email
    <span class="text-destructive" aria-hidden="true">*</span>
  </label>
  <input type="email" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;.htmx-request]:opacity-70" data-slot="input" id="ex-required-email" name="email" required="" placeholder="[email protected]"/>
</div>

Peer-disabled — label dims with the input

Render the input first with class="peer …", then the label after it. Tailwind's peer-* variant lets the label react to the input's disabled state.

No JS, no toggling classes by hand — Tailwind v4's peer-disabled variant on the label fires whenever its preceding sibling (marked .peer) carries disabled. Visual hierarchy stays accurate even when the field's state changes server-side via htmx.

<div class="flex flex-col-reverse gap-2">
  <Label htmlFor="locked">Locked field</Label>
  <Input id="locked" class="peer" name="locked" disabled />
</div>
<div class="flex flex-col-reverse gap-2">
  {{ label("Locked field", for_="locked") }}
  {{ input(id="locked", name="locked", extra_class="peer", disabled=true) }}
</div>
<div class="flex flex-col-reverse gap-2">
  {{template "label" (dict "Text" "Locked field" "For" "locked")}}
  {{template "input" (dict
    "ID" "locked" "Name" "locked" "Disabled" true
    "Attrs" (dict "class" "peer")
  )}}
</div>
<div class="flex flex-col-reverse gap-2">
  <.label for="locked">Locked field</.label>
  <.input id="locked" name="locked" class="peer" disabled />
</div>
<div class="flex w-full max-w-md flex-col-reverse gap-2">
  <input type="text" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;.htmx-request]:opacity-70 peer" data-slot="input" id="ex-peer-disabled-input" name="locked" value="locked" disabled=""/>
  <label for="ex-peer-disabled-input" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Locked field (label dims with the input)</label>
</div>

Further reading

API Reference

<Label>

PropTypeDefaultDescription
hx-*any
Any htmx attribute. Forwarded onto the underlying <label> element (already supported via attribute passthrough in all 5 flavours).htmxAttribute reference
htmlForstring
Id of the associated input. Renders as <label for="..."> — clicking the label focuses the input.
idstring
Set when another control needs aria-labelledby on this.
classstring
Extra Tailwind classes appended to the root element.