Label

Counter Label

A label with a character counter on the right edge of the line: the limit is visible before typing starts, and going over is highlighted without truncating pasted text.

  • label
  • counter
  • textarea
  • limit

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/label-006?lang=en

80 / 140

Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "label-006" (Counter Label) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/label-006.json

Registry item: https://vibeui.ru/r/label-006.json
Installs to: components/vibeui/label-006.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A label with a character counter on the right edge of the line: the limit is visible before typing starts, and going over is highlighted without truncating pasted text.

A label with a character counter on the right and a textarea below: overflow shows in color and speech while the text stays intact. One file, zero dependencies, state on useState.

## 3. How to use it
import { Label006 } from "@/components/vibeui/label-006"

<Label006
  label="Short description"
  limit={140}
  defaultValue="A pottery studio in the town centre."
/>

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-label-006-* palette — do not swap it for your theme tokens
- the absence of a hard maxLength: a silently truncated paste is someone's lost text
- tabular figures on the counter — proportional ones jerk the right edge on every character
- the three states through data-state instead of color classes: logic and paint stay in one place
- the live message only near the limit — announcing every character makes people turn the screen reader off
- the block's own light surface: without it the dark label disappears on a dark background

## 6. You may change
- the field name through label, the threshold through limit and the starting text through defaultValue
- the focus color through accent
- the warning and overflow colors through the --vibeui-label-006-warn and --vibeui-label-006-over variables
- the "almost at the limit" threshold of 20 characters inside the component

## 7. Rules
- The counter counts JavaScript string units: a surrogate-pair emoji counts as two, and exact counting needs Intl.Segmenter.
- The component owns the field: feed it from outside through defaultValue, not value.
- aria-invalid on overflow is a signal to the form: remember to block submission, or the server gets the long text.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/label-006.json
https://vibeui.ru/r/label-006.json

Клиентский компонент: значение поля держит useState, из него считается остаток. Один файл, без зависимостей, палитра в локальных переменных --vibeui-label-006-*. Счётчик стоит в строке подписи, а не под полем: лимит виден раньше, чем человек начал печатать. Состояние счётчика выражено атрибутом data-state (ok/near/over), цвета висят на нём — отдельных классов нет. Жёсткого maxLength у поля нет намеренно: вставленный из буфера текст не обрезается молча, вместо этого поле получает aria-invalid, а счётчик краснеет. Цифры набраны табличными (font-variant-numeric:tabular-nums), поэтому правый край не дёргается при вводе. Живое сообщение включается только у границы: озвучивать каждый символ — шум, из-за которого выключают экранного диктора.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useId, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Label006Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "defaultValue"
> & {
  label?: string
  limit?: number
  defaultValue?: string
  accent?: string
}

// Идея компонента: счётчик — часть подписи, а не подпись под полем. Он
// стоит на правом краю той же строки, поэтому лимит виден до того, как
// человек начал печатать. Жёсткого maxLength нет намеренно: вставленный
// из буфера текст не должен молча обрезаться — он окрашивает счётчик и
// помечает поле неверным, а решает человек.
const STYLES = `
:where([data-vibeui-block="label-006"]){
--vibeui-label-006-surface:oklch(1 0 0);
--vibeui-label-006-surface-border:oklch(0.91 0.006 265);
--vibeui-label-006-fg:oklch(0.24 0.016 265);
--vibeui-label-006-muted:oklch(0.54 0.014 265);
--vibeui-label-006-field-border:oklch(0.85 0.01 265);
--vibeui-label-006-accent:oklch(0.55 0.2 262);
--vibeui-label-006-warn:oklch(0.62 0.15 65);
--vibeui-label-006-over:oklch(0.55 0.2 25);
--vibeui-label-006-radius:0.625rem;
--vibeui-label-006-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="label-006"]{
box-sizing:border-box;width:100%;max-width:26rem;
padding:1rem;border-radius:0.875rem;
background:var(--vibeui-label-006-surface);
border:1px solid var(--vibeui-label-006-surface-border);
font-family:var(--vibeui-label-006-font);color:var(--vibeui-label-006-fg);
display:flex;flex-direction:column;gap:0.4375rem;
}
[data-vibeui-block="label-006"] [data-part="row"]{
display:flex;align-items:baseline;justify-content:space-between;gap:0.75rem;
}
[data-vibeui-block="label-006"] label{
font-size:0.875rem;font-weight:600;line-height:1.3;cursor:pointer;
}
/* Цифры счётчика прыгают по ширине, если шрифт пропорциональный:
   табличные цифры держат правый край на месте. */
[data-vibeui-block="label-006"] [data-part="count"]{
flex:none;font-size:0.75rem;font-variant-numeric:tabular-nums;
color:var(--vibeui-label-006-muted);transition:color .16s ease;
}
[data-vibeui-block="label-006"] [data-part="count"][data-state="near"]{color:var(--vibeui-label-006-warn)}
[data-vibeui-block="label-006"] [data-part="count"][data-state="over"]{color:var(--vibeui-label-006-over);font-weight:600}
[data-vibeui-block="label-006"] [data-part="live"]{
position:absolute;width:1px;height:1px;padding:0;margin:-1px;
overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0;
}
[data-vibeui-block="label-006"] textarea{
box-sizing:border-box;width:100%;min-height:5.5rem;resize:vertical;
padding:0.5rem 0.75rem;
font:inherit;font-size:0.9375rem;line-height:1.45;
color:var(--vibeui-label-006-fg);background:var(--vibeui-label-006-surface);
border:1px solid var(--vibeui-label-006-field-border);
border-radius:var(--vibeui-label-006-radius);
transition:border-color .16s ease,box-shadow .16s ease;
}
[data-vibeui-block="label-006"] textarea:focus-visible{
outline:none;border-color:var(--vibeui-label-006-accent);
box-shadow:0 0 0 3px color-mix(in oklab,var(--vibeui-label-006-accent) 22%,transparent);
}
[data-vibeui-block="label-006"] textarea[aria-invalid="true"]{border-color:var(--vibeui-label-006-over)}
[data-vibeui-block="label-006"] textarea[aria-invalid="true"]:focus-visible{
box-shadow:0 0 0 3px color-mix(in oklab,var(--vibeui-label-006-over) 22%,transparent);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="label-006"] *{animation:none!important;transition:none!important}}
`

/**
 * Подпись со счётчиком символов справа: лимит виден заранее, перебор
 * подсвечивается и не обрезает вставленный текст. Один файл, ноль
 * зависимостей.
 */
export function Label006({
  label = "Короткое описание",
  limit = 140,
  defaultValue = "Мастерская керамики в центре города: посуда ручной работы и занятия по выходным.",
  accent,
  className,
  style,
  ...props
}: Label006Props) {
  const id = useId()
  const countId = `${id}-count`
  const [value, setValue] = useState(defaultValue)
  const left = limit - value.length
  const state = left < 0 ? "over" : left <= 20 ? "near" : "ok"
  const palette = {
    ...(accent ? { "--vibeui-label-006-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-label-006" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="label-006"
        className={className}
        style={palette}
      >
        <div data-part="row">
          <label htmlFor={id}>{label}</label>
          <span data-part="count" data-state={state} id={countId}>
            {value.length} / {limit}
          </span>
        </div>
        <textarea
          id={id}
          name="summary"
          rows={3}
          value={value}
          aria-describedby={countId}
          aria-invalid={left < 0 || undefined}
          onChange={(event) => setValue(event.target.value)}
        />
        {/* Живое сообщение включается только у границы: озвучивать каждый
            символ — это шум, из-за которого выключают озвучку целиком. */}
        <p data-part="live" aria-live="polite">
          {state === "over"
            ? `Перебор на ${-left} символов`
            : state === "near"
              ? `Осталось ${left} символов`
              : ""}
        </p>
      </div>
    </>
  )
}