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.json2. Use it
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
/** @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
{% 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
{# 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
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
{{/*
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
<.label for="email">Email</.label>
<.input id="email" type="email" name="email" />View source
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
<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
<!--
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 [&.htmx-request]:opacity-70" data-slot="input" id="ex-explicit-email" name="email" placeholder="[email protected]"/>
</div>Further reading
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 [&.htmx-request]:opacity-70" data-slot="input" name="email" placeholder="[email protected]"/>
</label>
</div>Further reading
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 [&.htmx-request]:opacity-70" data-slot="input" id="ex-required-email" name="email" required="" placeholder="[email protected]"/>
</div>Further reading
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 [&.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>
| Prop | Type | Default | Description |
|---|---|---|---|
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying <label> element (already supported via attribute passthrough in all 5 flavours).htmxAttribute reference |
htmlFor | string | — | Id of the associated input. Renders as <label for="..."> — clicking the label focuses the input. |
id | string | — | Set when another control needs aria-labelledby on this. |
class | string | — | Extra Tailwind classes appended to the root element. |