Формы

Поля, метки, подсказки, флажки, переключатели, сегментный выбор и ползунок. Ни один элемент не подменяется другим: класс ставится на сам <input> или <select>.

griffincss-ui packages/ui/scss/_form.scss packages/ui/scss/_choice.scss packages/ui/scss/_range.scss

Поле и его обвязка

КлассНазначение
.gr-fieldОбёртка «метка + поле + подсказка» одной колонкой
.gr-labelМетка поля
.gr-hintПояснение под полем
.gr-errorСообщение об ошибке
.gr-successПодпись о принятом значении: пара к .gr-error
.gr-requiredЗвёздочка обязательности вручную — для групп; у поля с required она появляется сама
.gr-inputОднострочное поле любого типа — включая date, time и color: ни один селектор семьи не ограничен типом
.gr-input-no-spinЧисловое поле без системных стрелок шага: в счётчике количества шагают кнопки рядом. Клавиатурный шаг / остаётся
.gr-textareaМногострочное; растёт по содержимому (field-sizing: content: Chrome 123, Firefox 152, Safari 26.2) от двух строк до max-block-size, если его задать; тянется ручкой только по вертикали. Где свойства нет — высота прежняя
.gr-selectВыпадающий список; стрелка своя, отбита от края поля
Поле целиком
От трёх до шестидесяти символов.
Адрес должен содержать символ @.
<div class="gr-field">
  <label class="gr-label" for="mail">Почта</label>
  <input class="gr-input" id="mail" type="email" aria-describedby="mail-hint">
  <span class="gr-hint" id="mail-hint">Пришлём подтверждение.</span>
</div>

Стрелка у списка своя — и это единственное исключение. Положение системной стрелки задаёт браузер: на padding-inline-end она не реагирует и прижимается вплотную к краю поля, а в разных браузерах ещё и выглядит по-разному. Поэтому у .gr-select стоит appearance: none, а стрелка нарисована двумя линейными градиентами и отбита от края на --gr-control-padding-x. Цвет берётся из currentcolor, поэтому она следует и теме, и состоянию :disabled — SVG с зашитым цветом не следовал бы ни тому, ни другому. Сам элемент остаётся <select>: календарь у <input type="date"> и палитра у type="color" по-прежнему системные.

.gr-label не заменяет <label for>, а .gr-hintaria-describedby. Класс красит, связь создаёт разметка: без for клик по метке не переводит фокус в поле, а без aria-describedby подсказку не услышит тот, кто не видит её на экране.

Состояния

СостояниеСелекторЧто меняется
Фокус с клавиатуры:focus-visibleАкцентная граница и контур --gr-focus-width
Ошибка после ввода:user-invalidКрасная граница
Ошибка от сервера[aria-invalid="true"]То же самое
Ошибка на обёртке.gr-field[data-gr-state="error"]Красная граница у всех вложенных .gr-input / .gr-select / .gr-textarea / .gr-file; aria-invalid на контроле — для программы чтения с экрана
Принято сервером[aria-invalid="false"]Граница цвета успеха
Обязательное поле.gr-field:has([required])Звёздочка после текста метки
Недоступно:disabledУтопленный фон, приглушённый текст, курсор not-allowed
Только чтение:read-onlyФон поверхности; поле остаётся выделяемым и копируемым

Почему :user-invalid, а не :invalid. Пустое обязательное поле невалидно с первой миллисекунды загрузки страницы. По :invalid форма покраснела бы целиком до того, как человек успел что-то напечатать. :user-invalid срабатывает только после того, как поле тронули и покинули.

Почему успех не парен ошибке псевдоклассом. :user-valid браузер ставит любому непустому полю без ограничений: поле «Комментарий» без pattern и required валидно с первой введённой буквы. Форма зеленела бы сама собой по мере заполнения и превращалась в светофор. Поэтому успех показывается только по [aria-invalid="false"] — а его ставит та же серверная проверка, что ставит и "true".

Отвергнуто, принято, обязательно
Такой ИНН не найден в реестре.
Скидка 30 % применена к заказу.
Обязательное поле — по нему свяжется курьер.
<div class="gr-field">
  <label class="gr-label" for="promo">Промокод</label>
  <input class="gr-input" id="promo" type="text" value="OSEN30"
         aria-invalid="false" aria-describedby="promo-ok">
  <span class="gr-success" id="promo-ok">Скидка 30 % применена к заказу.</span>
</div>

<div class="gr-field">
  <label class="gr-label" for="phone">Телефон</label>
  <input class="gr-input" id="phone" type="tel" required aria-describedby="phone-hint">
  <span class="gr-hint" id="phone-hint">Обязательное поле — по нему свяжется курьер.</span>
</div>

Звёздочка декоративна. Обязательность объявляет атрибут required: его читает и платформа, и скринридер, и проверка формы. Звёздочка лишь показывает её глазами — и появляется от того же атрибута, без класса в разметке. Цвет и знак не заменяют слова: в примере выше обязательность продублирована подсказкой, как того же требует правило «статус читается словом» от сообщений.

Группе, где обязательность лежит не на одном контроле — набор флажков в <fieldset>, из которого нужно выбрать хотя бы один, — звёздочка ставится вручную: <legend class="gr-label gr-required">. Атрибута, выражающего такое условие, в платформе нет.

Недоступно и только чтение

Размеры

.gr-input, .gr-textarea и .gr-select — одна семья элементов управления: общая высота, общий кегль, общие состояния. Поэтому и размер у них задаётся одной парой классов, на любом из трёх.

КлассВысота
.gr-input-sm--gr-control-height-sm
--gr-control-height
.gr-input-lg--gr-control-height-lg
Три размера, три разных элемента

Группа с аддонами

.gr-input-group склеивает поле с подписью или кнопкой: скругления остаются на краях, соседние границы наезжают друг на друга на свою толщину, а сфокусированный элемент поднимается над соседями, чтобы контур фокуса не оказался наполовину закрыт.

Аддон и кнопка
в месяц
<div class="gr-input-group">
  <span class="gr-input-addon">₽</span>
  <input class="gr-input" type="number" aria-label="Сумма">
</div>

Края считаются по видимым полям. Скрытые потомки — <input type="hidden">, узел с атрибутом hidden, <template>, <script>, <datalist> — на вид группы не влияют, где бы они ни стояли: полное значение составного поля в скрытом поле, признак формы первым ребёнком, панель подсказки, которую живой поиск дописывает внутрь группы. Это допустимая разметка, а не обход.

Скрытые поля в группе: край остаётся у видимого соседа
<div class="gr-input-group">
  <select class="gr-select" aria-label="Код страны"><option>+7</option></select>
  <input class="gr-input" type="tel" aria-label="Номер">
  <input type="hidden" name="telephone">   <!-- последний ребёнок, но не край -->
</div>

Как это устроено и где деградирует. Скругления и сдвиг на ширину рамки задаются по :nth-child(1 of S) и :nth-last-child(1 of S), где S — «не скрытый». Прежние правила по :first-child / :last-child при этом остаются вторым ярусом: форма of S появилась в Chrome 111 и Firefox 113 — позже каскадных слоёв, — и браузер между двумя порогами отбрасывает новое правило, а группу рисует по-старому: со скрытым полем в конце край выходит прямым, без него всё как всегда. Это мягкая деградация, строка в таблице совместимости.

Выбор даты и времени

Ответ по умолчанию — нативное <input type="date">, и это не пробел. Локализация, разбор введённого, клавиатура, работа со скринридером и мобильный календарь там уже есть — и сделаны платформой лучше, чем их сделал бы виджет на разметке. Библиотеке остаётся оформить поле, и она это делает без единого нового класса: .gr-input работает на дате ровно так же, как на тексте. Второй путь — виджет data-gr-datetime из второго бандла слоя — существует и назван ниже, в подразделе «С подключённым griffinjs-fields.js», вместе со своей платой; заменой нативного поля он не является: на телефоне он и сам уступает системному календарю.

Ни один селектор семьи не ограничен типом поля, поэтому дате и времени достаются и рамка с фоном, и фокус, и :user-invalid, и [aria-invalid], и :disabled, и оба размера, и группа с аддонами.

<div class="gr-field">
  <label class="gr-label" for="from">Дата заезда</label>
  <input class="gr-input" id="from" type="date" required
         min="2026-01-01" aria-describedby="from-hint">
  <span class="gr-hint" id="from-hint">Не раньше начала года.</span>
</div>
Дата, время и диапазон — та же семья классов, что у текстового поля
Календарь открывает сам браузер.
Дата не может быть в прошлом.

Плата

Нативное поле — не «замена похуже», но и не бесплатное решение. Что именно вы отдаёте, лучше знать до того, как макет разойдётся с результатом.

Чего нельзяПодробность
Стилизовать календарьВыпадающая панель рисуется вне страницы средствами браузера. CSS-хуков к ней нет ни в одном движке — ни к сетке дней, ни к заголовку, ни к кнопкам месяцев
Задать формат и порядок полейИх определяет локаль браузера, а не lang документа. Читатель с английской локалью увидит mm/dd/yyyy на русской странице, и это правильно: формат — его настройка, а не ваша
Покрасить заготовку пустого поляmm/dd/yyyy в пустом поле — не ::placeholder, а часть редактора значения. Приглушённый цвет .gr-input::placeholder до неё не достаёт
Управлять значком календаряВ Blink это ::-webkit-calendar-picker-indicator, в Firefox — свой орган управления, в Safari его нет вовсе. Косметика на нём покупает один движок из трёх
Рассчитывать на зеркалирование в RTLBlink порядок полей не зеркалит, Firefox и WebKit зеркалят

Высота выровнена, но не везде до нуля. Внутренний редактор значения в Blink и WebKit несёт свои вертикальные отступы, и поле даты оказывалось выше соседнего текстового. Два правила в _form.scss снимают эти отступы, и на осях оформления по умолчанию, airy и strict расхождение в трёх движках равно нулю. Под предустановкой compact остаток есть: 2 px в WebKit и 0,75 px у type="time" в Blink — вдвое меньше, чем было, но не ноль. Firefox совпадает везде и точно. Замер — npm run test:browser.

Диапазон «от — до»

Пара дат — это два поля и одно правило между ними. База без скрипта — два нативных поля: границу задаёт разметка через min и max, платформа проверит введённое и сама пометит поле недействительным. С подключённым бандлом полей поля связываются одним атрибутом — data-gr-datetime="to: #b" на первом: значение «с» становится min у «по», значение «по» — max у «с», и панель одного показывает отрезок до другого. Подробности — в подразделе «Диапазон „с — по“» ниже.

<div class="gr-flex gr-gap-sm">
  <div class="gr-field gr-flex-1 gr-min-w-0">
    <label class="gr-label" for="a">С</label>
    <input class="gr-input" id="a" type="date" max="2026-12-31">
  </div>
  <div class="gr-field gr-flex-1 gr-min-w-0">
    <label class="gr-label" for="b">По</label>
    <input class="gr-input" id="b" type="date" max="2026-12-31">
  </div>
</div>

.gr-min-w-0 на колонках обязателен, а не для красоты: у флекс-элемента минимальная ширина по умолчанию равна ширине содержимого, а собственная ширина поля даты в Blink — около 154 px. Без него пара полей не сожмётся ниже своей суммы и уведёт страницу вбок на узком экране.

Числовой диапазон — другая задача и решается иначе: у ползунка пара «от — до» держит порядок значений сама, потому что там между ручками есть физическая связь. Это подраздел «С подключённым griffinjs.js» ниже. У двух дат такой связи нет, и порядок им не навязывается и со скриптом: «по» раньше «с» — ошибка ввода, которую показывает проверка границ, а не молчаливая правка второго поля. Диапазон дат одним контролом с двумя концами не делает и виджет второго бандла — это приложение, а не поле; связанная пара нативных полей — поле.

Флажок, радиокнопка, переключатель

Элемент не подменяется. Класс ставится на сам <input>, а не на спрятанный input рядом с оформленным <span>. Родная разметка сохраняет участие в форме, клавиатуру и объявление состояния скринридером — всё то, что при подмене приходится восстанавливать вручную и обычно восстанавливают наполовину. Цвет отметки у флажка и радиокнопки задаёт одно свойство accent-color: рисует их по-прежнему операционная система.

КлассЭлемент
.gr-checkbox<input type="checkbox">
.gr-radio<input type="radio">
.gr-switch<input type="checkbox"> в виде тумблера
.gr-choice<label>-обёртка: подпись в строку с элементом и кликабельна целиком
.gr-choice-startКонтрол по первой строке подписи, а не по центру блока: согласие в две-три строки
.gr-choice-rowСтрока настроек во всю ширину: подпись слева, тумблер у правого края, кликается вся строка
Выбор
Два модификатора: по первой строке и строка с тумблером
<label class="gr-choice gr-choice-start">
  <input class="gr-checkbox" type="checkbox" required>
  <span>Я прочитал условия использования и политику обработки персональных данных и согласен с ними</span>
</label>

<label class="gr-choice gr-choice-row">Подписка на рассылку <input class="gr-switch" type="checkbox"></label>

Почему это модификаторы, а не рецепт из утилит. .gr-choice задаёт align-items: center и display: inline-flex в слое griffincss.ui, а утилиты флекса — .gr-flex-items-start, .gr-flex — лежат в слое griffincss.core и проигрывают ему при любой специфичности: композицией контрол по первой строке не поставить. Строка во всю ширину собиралась через .gr-w-full из пакета утилит — а пакет компонентов обязан собираться и без него. Два модификатора по две строки стоят дешевле, чем эта оговорка в каждом проекте.

Тумблера среди типов <input> нет, поэтому его дорожка и бегунок нарисованы фоном: background-image с кругом и background-origin: content-box, чтобы отступ от края задавался обычным padding. Псевдоэлементы на <input> поддерживаются не везде, а фон работает всюду. Сам элемент остаётся <input type="checkbox">. Переезд бегунка снимается при prefers-reduced-motion: reduce; цвет продолжает меняться плавно — это не движение.

Сегментный переключатель

Ряд взаимоисключающих вариантов из обычной радиогруппы. Стрелки переключают выбор — это даёт платформа, скрипта не нужно. Оформляется <fieldset>, поэтому у группы есть имя: <legend> или aria-label.

gr-segmented
<fieldset class="gr-segmented" aria-label="Период">
  <label><input type="radio" name="period" value="d" checked><span>День</span></label>
  <label><input type="radio" name="period" value="w"><span>Неделя</span></label>
</fieldset>

Имя группы можно дать и <legend> — штатным способом <fieldset>, в том числе скрытым с экрана: края и внутренние рамки считаются по ярлыкам (label:first-of-type, label:last-of-type), а не по любым потомкам, поэтому ни <legend> первым, ни скрытое поле или <script> последним сегменты не ломают.

Имя группы — скрытый <legend>
Тема оформления
<fieldset class="gr-segmented">
  <legend class="gr-sr-only">Тема оформления</legend>
  <label><input type="radio" name="theme" value="auto" checked><span>Как в системе</span></label>
  <label><input type="radio" name="theme" value="light"><span>Светлая</span></label>
  <label><input type="radio" name="theme" value="dark"><span>Тёмная</span></label>
</fieldset>

Догфудинг. Переключатель темы на боковой панели этой документации собран ровно на этом компоненте. Раньше он был написан вручную в docs/style.css — потребность обнаружилась в фазе 6a, раньше, чем компонент запланировали. Ровно то, ради чего документация и собирается на самой библиотеке.

Выбор цвета и размера

Карточке товара нужны радио, которые выглядят не как радио: кружок цвета и плашка размера. Разметка — та же строка выбора, что и у флажка: <label class="gr-choice">, внутри <input class="gr-radio"> и образец следом — .gr-swatch для цвета, .gr-swatch-tag для плашки. Форма, клавиатура и объявление состояния остаются платформенными: стрелки переключают выбор внутри группы, Tab входит в неё один раз.

Цвет образца — переменная на элементе, style="--gr-swatch: …": цвет товара — данные страницы, а не оформление, и в библиотеке от него не остаётся ни одного литерала. Цвет — не подпись: у радио обязателен aria-labeltitle на строке даст ту же подсказку под курсором). Группе нужно имя — role="radiogroup" с aria-labelledby или <fieldset>.

<div class="gr-flex gr-flex-items-center gr-gap-sm" role="radiogroup" aria-labelledby="color-label">
  <span class="gr-text-sm gr-text-ink-secondary" id="color-label">Цвет</span>
  <label class="gr-choice" title="Чёрный">
    <input class="gr-radio" type="radio" name="color" value="black" checked aria-label="Чёрный">
    <span class="gr-swatch" style="--gr-swatch: #1f1f1f"></span>
  </label>
  <label class="gr-choice" title="Песочный">
    <input class="gr-radio" type="radio" name="color" value="sand" aria-label="Песочный">
    <span class="gr-swatch" style="--gr-swatch: #d9c3a0"></span>
  </label>
</div>

<div class="gr-flex gr-flex-items-center gr-flex-wrap gr-gap-sm" role="radiogroup" aria-labelledby="size-label">
  <span class="gr-text-sm gr-text-ink-secondary" id="size-label">Размер</span>
  <label class="gr-choice"><input class="gr-radio" type="radio" name="size" value="s"><span class="gr-swatch-tag">S</span></label>
  <label class="gr-choice"><input class="gr-radio" type="radio" name="size" value="m" checked><span class="gr-swatch-tag">M</span></label>
  <label class="gr-choice"><input class="gr-radio" type="radio" name="size" value="xl" disabled><span class="gr-swatch-tag">XL</span></label>
</div>
Образцы цвета и плашки размера: выбранный — кольцо или заливка акцентом
Цвет
Размер

Выбранный кружок получает кольцо акцента с просветом цвета подложки — на нём одинаково читаются и белый образец, и образец цвета акцента; выбранная плашка заливается акцентом. Сама радио остаётся в потоке, размером в пиксель и прозрачной, и прячется правилом с :has(): там, где его нет (Firefox до 121), правило не применяется вовсе, и радио остаётся видимой рядом с образцом — группа работает, только выглядит проще. display: none снял бы и клавиатуру, и эту деградацию.

Ползунок

.gr-range — оформление обычного <input type="range">: фильтр цены в каталоге, громкость, доля. Элемент не подменяется и здесь: клавиатура, шаг, min, max и участие в форме остаются платформенными.

РучкаПо умолчаниюЧто задаёт
--gr-range-track0.375remТолщину дорожки
--gr-range-thumb1.125remРазмер бегунка и высоту элемента
Обычный, изменённой толщины и недоступный
<label class="gr-label" for="volume">Громкость</label>
<input class="gr-range" id="volume" type="range" min="0" max="100" value="65">

Шесть вендорных псевдоэлементов, каждый — своим правилом. Неизвестный селектор в группе делает невалидной всю группу: WebKit, встретив ::-moz-range-thumb рядом со своим, выбросит правило целиком, и бегунок останется системным в обоих движках. При этом accent-color не снимается — он единственное, что делает ползунок частью библиотеки там, где appearance: none ещё не применён.

Заполненную часть дорожки Firefox рисует сам (::-moz-range-progress) и без скрипта. У WebKit псевдоэлемента для неё нет вовсе: там след рисует градиент, а градиенту нужна доля пройденного пути — число, которого в CSS взять неоткуда. Его пишет виджет range слоя GriffinJS одним свойством --gr-progress; как это выглядит — в подразделе о слое внизу страницы.

Чисто-CSS-приём проверен и отвергнут. Тень бегунка, обрезанная дорожкой с overflow: hidden, красит след без единой строки скрипта — но та же обрезка съедает и сам бегунок: круглая ручка крупнее дорожки просто исчезает. След без ручки не стоит ручки без следа, поэтому закраска у WebKit опциональна, а вид ползунка — нет.

Примеры использования

Целые формы уже разобраны в соседних главах: экран входа — на Sizing, ритм стопки полей — на Spacing. Здесь — сюжеты про сами контролы: ошибка, которую видно и слышно, поисковая строка с аддоном, пароль с кнопкой «показать», страница настроек на тогглах, фильтр цены и счётчик количества.

Ошибка от сервера: видно и слышно

Сервер отверг значение — поле получает aria-invalid="true" (граница краснеет сама), а текст ошибки встаёт под ним на месте подсказки: .gr-hint .gr-text-danger. Связь между ними — aria-describedby: без него скринридер прочитает «поле неверно», но не скажет почему. Пока ошибки нет, в том же месте живёт обычный серый .gr-hint — вёрстка не прыгает.

<div class="gr-field">
  <label class="gr-label" for="promo">Промокод</label>
  <input class="gr-input" id="promo" type="text" value="LETO25"
         aria-invalid="true" aria-describedby="promo-err">
  <p class="gr-hint gr-text-danger" id="promo-err">Промокод истёк 1 августа.</p>
</div>
Красная граница — от aria-invalid, причина — в связанном тексте

Промокод истёк 1 августа.

Когда ошибки красит скрипт после ответа сервера, ему проще ставить состояние на обёртку: .gr-field[data-gr-state="error"] красит все вложенные контролы, и список «поле → сообщение» из ответа ложится в одну строку на поле. aria-invalid при этом остаётся на контроле — для программы чтения с экрана, а не для рамки. Виджет data-gr-validate второго бандла ставит оба сам.

// ответ сервера: { promo: 'Промокод истёк 1 августа.' }
for (const [name, message] of Object.entries(errors)) {
  const control = form.elements[name];
  control.closest('.gr-field').dataset.grState = 'error';   // рамка
  control.setAttribute('aria-invalid', 'true');             // читалка
  control.nextElementSibling.textContent = message;         // .gr-error рядом
}
Состояние на обёртке: красятся оба контрола поля
В этот город курьером не возим — выберите самовывоз.

Поиск с кнопкой и подсказкой символа

Строка поиска или поле суммы: аддоны несут статичный контекст (₽, «в месяц», лупа-символ), кнопка завершает группу. Всё это — .gr-input-group: скругления по внешним углам, общая граница, фокус поднимается над соседями. Мелкий шрифт аддона и placeholder делают иерархию сами — дополнительных классов не нужно.

<div class="gr-input-group">
  <span class="gr-input-addon">⌕</span>
  <input class="gr-input" type="search" placeholder="Поиск по заказам" aria-label="Поиск по заказам">
  <button class="gr-btn gr-btn-primary">Найти</button>
</div>
Аддон-символ + поле + кнопка одной капсулой

Пароль: показать и скрыть

Поле пароля и кнопка рядом — та же .gr-input-group, что у поиска. Кнопка — переключатель: aria-pressed держит состояние, подпись говорит, что случится по нажатию. Переключают три строки на странице: type поля, aria-pressed и текст кнопки. Виджета здесь нет и не нужно — ради трёх строк заводить data-gr-* значило бы платить за них каждому, кто подключил слой. Значение при смене type остаётся на месте во всех трёх движках; autocomplete="current-password" оставляет менеджеру паролей его подсказку.

<div class="gr-input-group">
  <input class="gr-input" id="pass" type="password" autocomplete="current-password">
  <button class="gr-btn" type="button" aria-pressed="false"
          onclick="const f = this.previousElementSibling, on = f.type === 'password';
                   f.type = on ? 'text' : 'password';
                   this.setAttribute('aria-pressed', on);
                   this.textContent = on ? 'Скрыть' : 'Показать'">Показать</button>
</div>
Кнопка рабочая: type поля, aria-pressed и подпись меняются вместе

Страница настроек на тогглах

Список настроек — строки «подпись слева, тумблер справа». Каждая строка — .gr-choice с модификатором .gr-choice-row: блочный флекс во всю ширину, подпись и .gr-switch разведены по краям; .gr-py-3 .gr-border-b дают ритм и разделители. Кликается вся строка — это даёт сам label. До 0.26.0 то же собиралось из .gr-w-full .gr-flex-between, и рецепт по-прежнему работает — но требует пакета утилит.

<div style="max-width: 26rem">
  <label class="gr-choice gr-choice-row gr-py-3 gr-border-b">
    Уведомления о заказах <input class="gr-switch" type="checkbox" checked>
  </label>
  <label class="gr-choice gr-choice-row gr-py-3 gr-border-b">
    Письма о новинках <input class="gr-switch" type="checkbox">
  </label>
  <label class="gr-choice gr-choice-row gr-py-3">
    Тёмная тема <input class="gr-switch" type="checkbox" checked>
  </label>
</div>
Строка настроек: .gr-choice-row, кликается целиком

Фильтр цены в каталоге

Ползунок задаёт верхнюю границу, поле рядом показывает и принимает то же число: одно значение, два способа его ввести. Ни один из них не главный — и связывает их та пара строк скрипта, которую пишет потребитель. Библиотеке здесь принадлежит только вид: .gr-range, .gr-input-group с аддоном валюты и .gr-field как обёртка. Со слоем griffinjs.js эти строки не пишутся: связку берёт на себя параметр fields виджета — ниже.

<div class="gr-field">
  <label class="gr-label" for="price">Цена, до</label>
  <input class="gr-range" id="price" type="range" min="0" max="20000" step="500" value="8000">
  <div class="gr-input-group">
    <input class="gr-input gr-input-sm" id="price-num" type="number" value="8000" aria-label="Цена, до">
    <span class="gr-input-addon gr-input-sm">₽</span>
  </div>
</div>
Ползунок и поле показывают одно значение

С подключённым griffinjs.js

Всё выше работает без скрипта. Этот подраздел — о том, что добавляет ползунку опциональный слой GriffinJS: разметка и классы те же, скрипт читает data-gr-range и пишет на каждую ручку одно свойство — --gr-progress, долю пройденного пути от min до max. Рисует по ней CSS модуля. Набор для этого — griffinjs-core.js плюс griffinjs-range.js, 3,5 КБ gzip.

Закраска пройденного пути

Один атрибут на самом ползунке. Без скрипта у WebKit след не появляется, а сам ползунок работает как работал; в Firefox след есть в обоих случаях — его рисует платформа.

<input class="gr-range" type="range" min="0" max="100" value="65" data-gr-range>
Дорожка до ручки закрашена акцентом нужен griffinjs.js

Пара «от — до»

.gr-range-pair — два обычных ползунка в одной ячейке сетки. Дорожка и закрашенный отрезок принадлежат самой обёртке, а ползунки отдают ей всё, кроме ручек: наложением отрезок не собрать — дорожка полупрозрачна намеренно, оттенок currentcolor ложится на любую подложку, и верхняя дорожка не закрыла бы акцент нижней, а лишь притенила бы его.

Скрипту остаётся порядок значений: ручка, которую тянут за чужую границу, толкает вторую перед собой. Пересечься им нельзя — отрезок между ними вывернулся бы наизнанку. Без скрипта пара выглядит серой дорожкой с двумя ручками, и обе работают — мышью и с клавиатуры.

<div class="gr-range-pair" data-gr-range>
  <input class="gr-range" type="range" min="0" max="20000" step="500" value="4000" aria-label="Цена от">
  <input class="gr-range" type="range" min="0" max="20000" step="500" value="12000" aria-label="Цена до">
</div>
Фильтр цены «от — до»: закрашен отрезок между ручками нужен griffinjs.js
4000 — 12000 ₽

Подпись под парой ведёт разметка страницы, а не библиотека: числа рядом с фильтром у каждого каталога свои — валюта, разряды, «плюс» на верхней границе. Слой сообщает о каждом движении событием griffin:change на обёртке; в нём уже наведён порядок, поэтому слушать его, а не input самих ползунков, — единственный надёжный способ показать числа: свой обработчик на ползунке отработает раньше толчка второй ручки и покажет значения до него.

pair.addEventListener('griffin:change', (e) => {
  out.textContent = `${e.detail.from} — ${e.detail.to} ₽`;
});

Пара и числовые поля

У фасета цены ползунок и числа обязаны жить вместе: потянул ручку — число обновилось; вписал число — ручка уехала; форма отправляет одно значение, а не два. Параметр fields связывает пару с полями одним атрибутом — селекторов столько же, сколько ползунков, через запятую. Логику порядка при этом не переписывает никто: она та же, что у ручек.

Форму отправляют поля. name стоит у <input type="number">, у ползунков его нет — иначе в адрес уехали бы четыре параметра. Без скрипта остаются два рабочих числовых поля; со скриптом ползунок ложится поверх. Правда — в полях: страница вернулась с параметрами в них, и на монтаже ручки встают по полям, а пустое поле ставит свою ручку на край. Само поле от монтажа не заполняется — «фильтр не задан» остаётся пустым, пока ручку не тронут.

Ввод в поле двигает ручку сразу, на input, с зажимом по min/max; порядок наводится на change — «1» по пути к «1000» иначе толкнул бы вторую ручку. Событие griffin:change приходит на обёртку и при вводе в поле, с тем же detail.

<div class="gr-range-pair" data-gr-range="fields: #price-min, #price-max">
  <input class="gr-range" type="range" min="0" max="20000" step="500" value="0" aria-label="Цена от">
  <input class="gr-range" type="range" min="0" max="20000" step="500" value="20000" aria-label="Цена до">
</div>
<div class="gr-flex gr-gap-sm">
  <input class="gr-input gr-input-sm gr-flex-1" id="price-min" type="number" name="price_min" min="0" max="20000" step="500" aria-label="Цена от">
  <input class="gr-input gr-input-sm gr-flex-1" id="price-max" type="number" name="price_max" min="0" max="20000" step="500" aria-label="Цена до">
</div>
Пара над полями: потяните ручку или впишите число нужен griffinjs.js
Параметр data-gr-rangeПо умолчаниюЧто делает
fieldsСелекторы числовых полей через запятую, по одному на ползунок: ручка пишет в поле, поле двигает ручку. Поле не найдено — виджет не поднимается и называет селектор в консоли

Счётчик количества в корзине

«Минус — число — плюс» собирается из того же .gr-input-group, что и поисковая строка: кнопки по краям, поле посередине, .gr-text-center ставит число в середину. Поле — настоящий <input type="number"> с min, max и step: количество уезжает вместе с формой, а на телефоне inputmode="numeric" открывает цифровую клавиатуру. Кнопкам нужен aria-label — «плюс» и «минус» знаками скринридер прочитает как пунктуацию.

Прибавляют и убавляют родные методы поляstepUp() и stepDown(): они уважают min, max и step, поэтому ниже единицы счётчик не уйдёт и выше сотни не поднимется. Библиотеке для этого добавлять нечего — кнопке остаётся позвать метод у соседа. Счётчики в демонстрации рабочие, нажмите.

Системные стрелки у поля при этом снимает .gr-input-no-spin: рядом с парой кнопок они второй орган управления тем же числом — вдвое мельче «плюса» и вплотную к нему. Класс, а не автоматика по соседству с кнопкой: разметка нигде не говорит, что кнопка рядом шаговая, и группа «число + Применить» осталась бы без единственного способа шагнуть мышью. Клавиатуры это не касается — и шагают по значению и без видимых стрелок.

<div class="gr-input-group" style="max-width: 9rem">
  <button class="gr-btn" type="button" aria-label="Убрать одну штуку"
          onclick="this.nextElementSibling.stepDown()">−</button>
  <input class="gr-input gr-input-no-spin gr-text-center" type="number"
         inputmode="numeric" value="1" min="1" max="99" aria-label="Количество">
  <button class="gr-btn" type="button" aria-label="Добавить одну штуку"
          onclick="this.previousElementSibling.stepUp()">+</button>
</div>
Кнопки рабочие: stepUp() и stepDown() у самого поля

Счётчик у границы: кнопка гаснет сама

Счётчик выше на единице не гаснет: stepDown() просто не даёт значению уйти ниже min, а кнопка остаётся нажимаемой и молчащей. Для корзины этого мало — «минус» на единице должен выглядеть недоступным. Гасит его ваш код: библиотека про остатки на складе и про минимальную партию ничего не знает.

Ниже — тот же счётчик с max="5", чтобы граница была в двух нажатиях, и с делегированным обработчиком на всю группу вместо двух атрибутов: в списке товаров счётчиков десятки, и вешать на каждый по паре обработчиков незачем. Он же шлёт change — сами шаговые методы события не шлют.

function grQty(group) {
  const input = group.querySelector('input');
  const minus = group.querySelector('[data-step="-1"]');
  const plus  = group.querySelector('[data-step="1"]');

  const sync = () => {
    minus.disabled = Number(input.value) <= Number(input.min);
    plus.disabled  = Number(input.value) >= Number(input.max);
  };

  group.addEventListener('click', (event) => {
    const button = event.target.closest('[data-step]');
    if (!button) return;

    button.dataset.step === '1' ? input.stepUp() : input.stepDown();
    input.dispatchEvent(new Event('change', { bubbles: true }));
  });

  input.addEventListener('input', sync);
  input.addEventListener('change', sync);
  sync();
}
Дойдите до 1 или до 5 — кнопка на этом краю погаснет
событие change: пока не было

Событие change шаговые методы не шлют. Это поведение платформы, а не недосмотр: значение меняет скрипт, а не пользователь. Если на количество подписан пересчёт корзины, пошлите событие сами — как в примере выше; подпись рядом со счётчиком слушает именно его. В первом примере обработчики стоят прямо в разметке: так блок остаётся самодостаточным при копировании, и с него удобно начинать.

Класса .gr-qty здесь нет по тем же причинам, что .gr-price и .gr-sticker: он был бы именем для готовой группы, а не новым CSS. Единственное, чего не хватало по-настоящему, — .gr-input-no-spin: снять системные стрелки композицией утилит нельзя.

С подключённым griffinjs-fields.js

Второй бандл слоя — девять полей, которых у платформы нет. Разметка та же семья .gr-field, у каждого поля есть база без скрипта, а griffinjs.js от них не растёт ни на байт: файл подключают только те, кому он нужен, после griffinjs.js или griffinjs-core.js. Набор «ядро + anchor + поля» — 13,9 КБ gzip. Здесь — поля, которые чаще всего стоят в форме рядом; остальные и подробности — на странице «Поля форм».

Телефон: код страны и маска

Список кодов — из разметки или из таблицы стран griffinjs-countries.js; имя страны и флаг — у платформы, в языке страницы. Без скрипта — type="tel" и тот же список в группе.

<div class="gr-input-group" data-gr-phone="country: ru">
  <select class="gr-select" name="code" aria-label="Код страны"></select>
  <input class="gr-input" type="tel" name="phone" autocomplete="tel-national">
</div>
Состав из таблицы стран нужен griffinjs-fields.js

Дата: второй путь и его плата

Разметка — то же нативное поле, что выше. На тач-устройстве виджет её не трогает: системный календарь остаётся, и решает это pointerType каждого события. На указателе мыши тип подменяется на text, надевается маска по локали из lang документа — формат наконец ваш, а не браузера, — открывается панель, а ISO уходит в скрытый спутник с прежним именем. Плата: на текстовом поле платформа не проверяет min и max — проверяет виджет через setCustomValidity, сообщение о границах берёт у платформы, о невозможной дате — своё.

<input class="gr-input" type="date" name="from" min="2026-01-01" required data-gr-datetime>
На компьютере — маска и панель, на телефоне — системный календарь нужен griffinjs-fields.js
В пределах 2026 года.

Диапазон «с — по»

Пара нативных полей из раздела «Диапазон „от — до“», связанная атрибутом to на первом. Значение «с» — min у «по», значение «по» — max у «с»: проверка границ, сообщения и недоступные дни в панели работают тем же путём, что у одиночного поля. Пустое поле открывает панель на месяце соседа, дни между концами помечены в обеих панелях. На тач-устройстве работает только связка границ — системный календарь скрывает недоступное сам. Авторская граница остаётся, если она уже; очистка возвращает её.

<input class="gr-input" id="a" type="date" name="since" data-gr-datetime="to: #b">
<input class="gr-input" id="b" type="date" name="until" data-gr-datetime>
Заезд и выезд: второе поле не раньше первого нужен griffinjs-fields.js

Сумма с разрядами

Отдельное поле, а не маска: type="number" разрядов не показывает, inputmode не форматирует, а маска знает только фиксированные слоты. На экране — «1 250 000,50» по локали документа, в скрытом спутнике с прежним именем — 1250000.50; разряды встают на ходу, каретка держится за той же цифрой, вставка «1 250 000,50» и «1250000.50» читается одинаково. Валюта и единица — в аддоне рядом. База без скрипта — обычное текстовое поле: сервер получит то, что набрано, и разбор на сервере обязателен в любом случае.

<div class="gr-input-group">
  <input class="gr-input" type="text" inputmode="decimal" name="amount" min="0" data-gr-number="decimals: 2">
  <span class="gr-input-addon">₽</span>
</div>
Разряды и дробь по локали, число — в форму нужен griffinjs-fields.js

Счётчик символов

<textarea class="gr-textarea" maxlength="200" data-gr-counter="format: {left} из {max}"></textarea>
Остаток к пределу после поля, с aria-live нужен griffinjs-fields.js

Сводка ошибок вместо пузыря

<form data-gr-validate>
  …
  <button class="gr-btn gr-btn-primary" type="submit">Отправить</button>
</form>
Все непройденные поля одним списком, ссылками к полям нужен griffinjs-fields.js