Help fields
Five form fields that differ in how they explain themselves: an inline hint, a tooltip beside the label, a live character counter, a long description block and help text with a link. Every help string joins the control aria-describedby chain, so it reaches screen readers even when it is only visible on hover.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/field-helpDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install lucide-reactCopy the source
components/ui/field-help.tsx"use client"
import * as React from "react"
import { HelpCircle, Info } from "lucide-react"
import { cn } from "@/lib/utils"
/* Help field family: 5 form fields that share the same skeleton but each carry a
different HELP apparatus (an inline hint, a tooltip beside the label, a character
counter, a long description block, a link to the relevant documentation). In
every field the label is bound to the control with htmlFor/id and ALL the help
text enters the aria-describedby chain, so a screen reader sees it too.
Invalidity falls back to the standard danger appearance ON THE CONTROL via
aria-invalid. The ids come from useId. Color comes ONLY from tokens. Renders with
no props too. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const gap: Record<StyledSize, string> = {
sm: "gap-1",
md: "gap-1.5",
lg: "gap-2",
xl: "gap-2.5",
}
const labelText: Record<StyledSize, string> = {
sm: "text-xs",
md: "text-sm",
lg: "text-sm",
xl: "text-base",
}
const helpText: Record<StyledSize, string> = {
sm: "text-xs",
md: "text-xs",
lg: "text-sm",
xl: "text-sm",
}
const controlHeight: Record<StyledSize, string> = {
sm: "h-8 text-sm",
md: "h-9 text-sm",
lg: "h-10 text-base",
xl: "h-12 text-base",
}
const controlBase =
"w-full rounded-md border border-field-border bg-transparent px-3 py-1 text-foreground outline-none transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-danger aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40"
const DEFAULT_LABEL = "Display name"
const DEFAULT_PLACEHOLDER = "Ada Lovelace"
interface FieldProps {
className?: string
size?: StyledSize
label?: React.ReactNode
description?: React.ReactNode
}
function FieldLabel({
htmlFor,
size,
children,
className,
}: {
htmlFor: string
size: StyledSize
children: React.ReactNode
className?: string
}) {
return (
<label
htmlFor={htmlFor}
data-slot="styled-field-label"
className={cn("w-fit font-medium leading-none text-foreground select-none", labelText[size], className)}
>
{children}
</label>
)
}
/* Hint: kontrolun altinda ikonlu, kisa bir ipucu satiri. */
export function HintHelpField({
className,
size = "md",
label = DEFAULT_LABEL,
description = "Two to thirty characters. Letters and spaces only.",
}: FieldProps) {
const base = React.useId()
const controlId = `${base}-control`
const hintId = description != null && description !== false ? `${base}-hint` : undefined
return (
<div data-slot="styled-field" className={cn("flex w-full max-w-xs flex-col", gap[size], className)}>
<FieldLabel htmlFor={controlId} size={size}>
{label}
</FieldLabel>
<input id={controlId} placeholder={DEFAULT_PLACEHOLDER} aria-describedby={hintId} className={cn(controlBase, controlHeight[size])} />
{hintId ? (
<p
id={hintId}
data-slot="styled-field-description"
className={cn(
"flex items-center gap-1.5 leading-snug text-muted-foreground [&>svg]:size-3.5 [&>svg]:shrink-0 [&>i]:text-xs [&>i]:leading-none",
helpText[size]
)}
>
<Info />
<span>{description}</span>
</p>
) : null}
</div>
)
}
/* Tooltip: a help button beside the label that shows the description on
hover/focus. The description is always in the aria-describedby chain, independent
of its visibility. */
export function TooltipField({
className,
size = "md",
label = DEFAULT_LABEL,
description = "This is the name other people see on your profile.",
}: FieldProps) {
const base = React.useId()
const controlId = `${base}-control`
const tipId = `${base}-tip`
const [open, setOpen] = React.useState(false)
return (
<div data-slot="styled-field" className={cn("flex w-full max-w-xs flex-col", gap[size], className)}>
<div className="flex items-center gap-1.5">
<FieldLabel htmlFor={controlId} size={size}>
{label}
</FieldLabel>
<span className="relative inline-flex">
<button
type="button"
aria-label="Show help for this field"
aria-expanded={open}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
className="inline-flex size-4 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:size-3.5 [&>svg]:shrink-0 [&>i]:text-xs [&>i]:leading-none"
>
<HelpCircle />
</button>
{open ? (
<span
role="tooltip"
className="absolute bottom-full left-1/2 z-10 mb-1.5 w-48 -translate-x-1/2 rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs leading-snug text-popover-foreground shadow-md"
>
{description}
</span>
) : null}
</span>
</div>
<input id={controlId} placeholder={DEFAULT_PLACEHOLDER} aria-describedby={tipId} className={cn(controlBase, controlHeight[size])} />
<span id={tipId} className="sr-only">
{description}
</span>
</div>
)
}
/* Counter: a character counter. Once the limit is exceeded the control turns aria-invalid and the counter shifts to the danger tone. State is held at the top, not in the unmounting subtree. */
export function CounterField({
className,
size = "md",
label = "Bio",
description = "A short line about yourself.",
}: FieldProps) {
const base = React.useId()
const controlId = `${base}-control`
const descId = description != null && description !== false ? `${base}-desc` : undefined
const countId = `${base}-count`
const maxLength = 60
const [value, setValue] = React.useState("")
const over = value.length > maxLength
const describedBy = [descId, countId].filter(Boolean).join(" ")
return (
<div data-slot="styled-field" className={cn("flex w-full max-w-xs flex-col", gap[size], className)}>
<FieldLabel htmlFor={controlId} size={size}>
{label}
</FieldLabel>
<input
id={controlId}
placeholder="Builds design systems."
value={value}
onChange={(e) => setValue(e.target.value)}
aria-describedby={describedBy}
aria-invalid={over ? true : undefined}
className={cn(controlBase, controlHeight[size])}
/>
<div className="flex items-start justify-between gap-3">
{descId ? (
<p id={descId} data-slot="styled-field-description" className={cn("leading-snug text-muted-foreground", helpText[size])}>
{description}
</p>
) : (
<span />
)}
<span
id={countId}
aria-live="polite"
className={cn("shrink-0 font-mono tabular-nums leading-snug", helpText[size], over ? "font-medium text-danger" : "text-muted-foreground")}
>
{value.length}/{maxLength}
</span>
</div>
</div>
)
}
/* Description: kontrolun USTUNDE duran genis aciklama blogu - kural uzunsa
kullanici yazmadan once okur. */
export function DescriptionField({
className,
size = "md",
label = "Workspace slug",
description = "Used in your workspace URL. Lowercase letters, numbers and dashes only, between three and forty characters. You can change it later, but old links stop working.",
}: FieldProps) {
const base = React.useId()
const controlId = `${base}-control`
const descId = description != null && description !== false ? `${base}-desc` : undefined
return (
<div data-slot="styled-field" className={cn("flex w-full max-w-xs flex-col", gap[size], className)}>
<FieldLabel htmlFor={controlId} size={size}>
{label}
</FieldLabel>
{descId ? (
<p
id={descId}
data-slot="styled-field-description"
className={cn("rounded-md border-l-2 border-border bg-surface-2 px-3 py-2 leading-relaxed text-muted-foreground", helpText[size])}
>
{description}
</p>
) : null}
<input id={controlId} placeholder="acme-design" aria-describedby={descId} className={cn(controlBase, controlHeight[size])} />
</div>
)
}
/* Link: a link inside the description pointing to the relevant document; the link carries its own focus ring. */
export function LinkField({
className,
size = "md",
label = "API key",
description = "Paste the secret key from your dashboard.",
helpHref = "/docs/agents",
}: FieldProps & { helpHref?: string }) {
const base = React.useId()
const controlId = `${base}-control`
const descId = description != null && description !== false ? `${base}-desc` : undefined
return (
<div data-slot="styled-field" className={cn("flex w-full max-w-xs flex-col", gap[size], className)}>
<FieldLabel htmlFor={controlId} size={size}>
{label}
</FieldLabel>
<input id={controlId} placeholder="sk_live_..." aria-describedby={descId} className={cn(controlBase, controlHeight[size])} />
{descId ? (
<p id={descId} data-slot="styled-field-description" className={cn("leading-snug text-muted-foreground", helpText[size])}>
{description}{" "}
<a
href={helpHref}
className="rounded-sm font-medium text-primary underline underline-offset-2 outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Where do I find it?
</a>
</p>
) : null}
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Hint
A short hint with an icon sitting under the control.
Two to thirty characters. Letters and spaces only.
import { HintHelpField } from "@/components/ui/field-help"
<HintHelpField />Two to thirty characters. Letters and spaces only.
Two to thirty characters. Letters and spaces only.
Two to thirty characters. Letters and spaces only.
Two to thirty characters. Letters and spaces only.
Tooltip
A help button next to the label that reveals the text on hover or focus.
import { TooltipField } from "@/components/ui/field-help"
<TooltipField />Counter
A live character counter that turns danger and sets aria-invalid past the limit.
A short line about yourself.
0/60import { CounterField } from "@/components/ui/field-help"
<CounterField />A short line about yourself.
0/60A short line about yourself.
0/60A short line about yourself.
0/60A short line about yourself.
0/60Description
A longer rules block above the control, read before typing starts.
Used in your workspace URL. Lowercase letters, numbers and dashes only, between three and forty characters. You can change it later, but old links stop working.
import { DescriptionField } from "@/components/ui/field-help"
<DescriptionField />Used in your workspace URL. Lowercase letters, numbers and dashes only, between three and forty characters. You can change it later, but old links stop working.
Used in your workspace URL. Lowercase letters, numbers and dashes only, between three and forty characters. You can change it later, but old links stop working.
Used in your workspace URL. Lowercase letters, numbers and dashes only, between three and forty characters. You can change it later, but old links stop working.
Used in your workspace URL. Lowercase letters, numbers and dashes only, between three and forty characters. You can change it later, but old links stop working.
Link
Help text carrying a link to the relevant docs, with its own focus ring.
Paste the secret key from your dashboard. Where do I find it?
import { LinkField } from "@/components/ui/field-help"
<LinkField />Paste the secret key from your dashboard. Where do I find it?
Paste the secret key from your dashboard. Where do I find it?
Paste the secret key from your dashboard. Where do I find it?
Paste the secret key from your dashboard. Where do I find it?
ai2 Help fields: 5 styled variations on the token system
The ai2 Help fields are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around the affordances a field uses to explain itself. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: these are static layouts; no motion library work is required. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, there is no motion to reduce.
What is in the ai2 Help fields?
5 exports in one file: Hint, Tooltip, Counter, Description and Link. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: these are static layouts; no motion library work is required.
- Reduced-motion aware: Under prefers-reduced-motion, there is no motion to reduce.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Help fields sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.