Help labels
Five labels that explain their field: a tooltip you can reach with the keyboard, an inline hint, an info help line, an Optional marker and a description. Help text is wired to the input with aria-describedby, and every label is a real label element bound with htmlFor.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/label-helpDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/label-help.tsx"use client"
import * as React from "react"
import { CircleHelp, Info } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Help label family: 5 labels, each carrying a different help behavior (a tooltip
that opens with the keyboard, an inline hint, an info help text, an optional
marker, a description paragraph). The label is a REAL <label> bound with htmlFor
to the rendered demo input (the id comes from React.useId()). The help texts are
bound to the input with aria-describedby, so a screen reader hears them too.
The tooltip opens not only on hover but also on FOCUS, and closes with Escape;
the trigger is a real button and carries a focus ring. useState always lives in
the root component, never inside a subtree that opens and closes. Color comes
ONLY from tokens, via alpha color-mix. The motion is disabled through
useReducedMotion(). */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const text: Record<StyledSize, string> = {
sm: "text-xs",
md: "text-sm",
lg: "text-sm",
xl: "text-base",
}
const helpText: Record<StyledSize, string> = {
sm: "text-[10px]",
md: "text-xs",
lg: "text-xs",
xl: "text-sm",
}
/* Demo input yuksekligi base Button olcegine hizali. */
const inputHeight: Record<StyledSize, string> = {
sm: "h-8",
md: "h-9",
lg: "h-10",
xl: "h-12",
}
const base =
"inline-flex w-fit items-center gap-1.5 font-medium leading-none text-foreground select-none [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const field = "flex w-fit flex-col gap-1.5"
const inputBase =
"w-52 rounded-md border border-field-border bg-transparent px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
type Props = Omit<React.ComponentProps<"label">, "htmlFor"> & { size?: StyledSize }
/* Tooltip: it opens on hover AND focus, and Escape closes it. The trigger is a real
button, and the tooltip has role="tooltip" and is bound to the trigger with
aria-describedby. */
export function TooltipLabel({
className,
size = "md",
children,
hint = "We only use this to send account emails.",
...props
}: Props & { hint?: string }) {
const id = React.useId()
const tipId = `${id}-tip`
const reduce = useReducedMotion()
const [open, setOpen] = React.useState(false)
return (
<span data-slot="styled-label" className={field}>
<span className="inline-flex items-center gap-1.5">
<label htmlFor={id} className={cn(base, text[size], className)} {...props}>
{children ?? "Email"}
</label>
<span className="relative inline-flex">
<button
type="button"
aria-label="More information"
aria-describedby={open ? tipId : undefined}
className="inline-flex size-5 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"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false)
}}
>
<CircleHelp />
</button>
<AnimatePresence>
{open ? (
<motion.span
id={tipId}
role="tooltip"
className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-56 -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"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
{hint}
</motion.span>
) : null}
</AnimatePresence>
</span>
</span>
<input
id={id}
type="text"
placeholder="you@example.com"
className={cn(inputBase, inputHeight[size])}
/>
</span>
)
}
/* Hint: etiketin yaninda kucuk, sessiz bir ipucu metni. */
export function HintLabel({
className,
size = "md",
children,
hint = "max 40 characters",
...props
}: Props & { hint?: string }) {
const id = React.useId()
const hintId = `${id}-hint`
return (
<span data-slot="styled-label" className={field}>
<span className="inline-flex w-full items-baseline gap-2">
<label htmlFor={id} className={cn(base, text[size], className)} {...props}>
{children ?? "Display name"}
</label>
<span id={hintId} className={cn(helpText[size], "font-normal text-muted-foreground")}>
{hint}
</span>
</span>
<input
id={id}
type="text"
aria-describedby={hintId}
placeholder="Ada Lovelace"
className={cn(inputBase, inputHeight[size])}
/>
</span>
)
}
/* InfoHelp: an info-toned help row with an icon below the input. */
export function InfoHelpLabel({
className,
size = "md",
children,
help = "Use the address your team already knows.",
...props
}: Props & { help?: string }) {
const id = React.useId()
const helpId = `${id}-help`
return (
<span data-slot="styled-label" data-tone="info" className={field}>
<label htmlFor={id} className={cn(base, text[size], className)} {...props}>
{children ?? "Work email"}
</label>
<input
id={id}
type="text"
aria-describedby={helpId}
placeholder="you@example.com"
className={cn(inputBase, inputHeight[size])}
/>
<span
id={helpId}
className={cn(
helpText[size],
"inline-flex items-center gap-1.5 text-info [&>svg]:size-3.5 [&>svg]:shrink-0 [&>i]:text-xs [&>i]:leading-none"
)}
>
<Info aria-hidden="true" />
{help}
</span>
</span>
)
}
/* Optional: etiketin ardinda sessiz bir Optional isareti. */
export function OptionalLabel({ className, size = "md", children, ...props }: Props) {
const id = React.useId()
return (
<span data-slot="styled-label" className={field}>
<label htmlFor={id} className={cn(base, text[size], className)} {...props}>
{children ?? "Company"}
<span
className={cn(
helpText[size],
"rounded px-1.5 py-0.5 font-normal text-muted-foreground [background-color:color-mix(in_oklab,var(--color-foreground)_8%,transparent)]"
)}
>
Optional
</span>
</label>
<input
id={id}
type="text"
placeholder="Acme Inc."
className={cn(inputBase, inputHeight[size])}
/>
</span>
)
}
/* Description: etiketin altinda tam cumlelik aciklama, input'a bagli. */
export function DescriptionLabel({
className,
size = "md",
children,
description = "This is the name other people will see on your profile.",
...props
}: Props & { description?: string }) {
const id = React.useId()
const descId = `${id}-desc`
return (
<span data-slot="styled-label" className="flex w-fit max-w-64 flex-col gap-1.5">
<label htmlFor={id} className={cn(base, text[size], className)} {...props}>
{children ?? "Display name"}
</label>
<span id={descId} className={cn(helpText[size], "leading-snug text-muted-foreground")}>
{description}
</span>
<input
id={id}
type="text"
aria-describedby={descId}
placeholder="Ada Lovelace"
className={cn(inputBase, inputHeight[size], "mt-0.5")}
/>
</span>
)
}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.
Tooltip
A help button that opens a tooltip on hover and on keyboard focus, and closes on Escape.
import { TooltipLabel } from "@/components/ui/label-help"
<TooltipLabel>Email</TooltipLabel>Hint
A short inline hint next to the label, bound to the input with aria-describedby.
import { HintLabel } from "@/components/ui/label-help"
<HintLabel>Display name</HintLabel>Info help
An info-toned help line with an icon under the input.
import { InfoHelpLabel } from "@/components/ui/label-help"
<InfoHelpLabel>Work email</InfoHelpLabel>Optional
A quiet Optional marker after the label text.
import { OptionalLabel } from "@/components/ui/label-help"
<OptionalLabel>Company</OptionalLabel>Description
A full sentence of description between the label and the input.
import { DescriptionLabel } from "@/components/ui/label-help"
<DescriptionLabel>Display name</DescriptionLabel>ai2 Help labels: 5 styled variations on the token system
The ai2 Help labels are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around a form label with a help affordance. 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: framer-motion fades the tooltip in and out; the rest is static. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the tooltip skips the transform and only fades.
What is in the ai2 Help labels?
5 exports in one file: Tooltip, Hint, Info help, Optional and Description. 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: framer-motion fades the tooltip in and out; the rest is static.
- Reduced-motion aware: Under prefers-reduced-motion, the tooltip skips the transform and only fades.
- 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 labels 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.