Motiva
Мои коллекции
← каталог

Floating Label Input

Поле с плавающей подписью: подпись уезжает в вырез рамки. Состояние «непустое» ловится чистым CSS, поэтому подпись права и после автозаполнения браузером.

cssclickrevealbasic~2 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
label?string"Рабочая почта"
type?string"email"
hint?stringПодсказка под полем (формат «мы не шлём спам»).
error?stringТекст ошибки — поле подсвечивается и коротко вздрагивает.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/floating-label-input.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useId, useState } from "react";

export interface FloatingLabelInputProps {
  label?: string;
  type?: string;
  /** Подсказка под полем (формат «мы не шлём спам»). */
  hint?: string;
  /** Текст ошибки — поле подсвечивается и коротко вздрагивает. */
  error?: string;
}

/**
 * Поле с плавающей подписью: подпись уезжает в вырез рамки, когда поле в фокусе ИЛИ
 * непустое. Состояние «непустое» ловится чистым CSS (`:not(:placeholder-shown)`), а не
 * стейтом — поэтому подпись правильно стоит и после автозаполнения браузером,
 * когда никаких событий React не приходит.
 */
export function FloatingLabelInput({
  label = "Рабочая почта",
  type = "email",
  hint,
  error,
}: FloatingLabelInputProps) {
  const id = `fli-${useId().replace(/[:]/g, "")}`;
  const [shake, setShake] = useState(0);

  return (
    <div className={`fli${error ? " is-error" : ""}`} key={shake}>
      <style>{`
        .fli { --fli-accent: var(--ds-accent, oklch(0.72 0.15 300)); --fli-line: var(--ds-border, oklch(0.36 0.02 270));
          position: relative; width: min(320px, 84vw); }
        .fli.is-error { --fli-accent: oklch(0.68 0.19 25); --fli-line: oklch(0.55 0.16 25); }
        .fli-field { position: relative; }
        .fli-input {
          width: 100%; box-sizing: border-box; padding: 15px 14px 13px;
          border-radius: 12px; border: 1.5px solid var(--fli-line);
          background: var(--ds-surface, oklch(0.16 0.02 270)); color: var(--ds-fg, oklch(0.95 0.01 265));
          font: 400 0.95rem/1.2 var(--ds-font, system-ui, sans-serif); outline: none;
          transition: border-color 0.2s, box-shadow 0.2s;
        }
        .fli-input::placeholder { color: transparent; }
        .fli-input:focus { border-color: var(--fli-accent);
          box-shadow: 0 0 0 4px color-mix(in oklch, var(--fli-accent) 22%, transparent); }
        .fli-label {
          position: absolute; left: 12px; top: 50%; translate: 0 -50%; padding: 0 4px;
          color: var(--ds-muted, oklch(0.63 0.02 265)); font: 400 0.95rem/1 var(--ds-font, system-ui, sans-serif);
          pointer-events: none; background: var(--ds-surface, oklch(0.16 0.02 270));
          transition: translate 0.18s var(--ds-ease-out, cubic-bezier(0.16,1,0.3,1)), font-size 0.18s, color 0.18s;
        }
        /* ключевой приём: :not(:placeholder-shown) знает про автозаполнение, в отличие от стейта */
        .fli-input:focus + .fli-label,
        .fli-input:not(:placeholder-shown) + .fli-label {
          translate: 0 -30px; font-size: 0.73rem; color: var(--fli-accent);
        }
        .fli-msg { margin-top: 7px; font-size: 0.74rem; color: var(--ds-muted, oklch(0.62 0.02 265)); }
        .fli.is-error .fli-msg { color: oklch(0.72 0.17 25); }
        @keyframes fli-shake { 10%,90%{translate:-1px 0} 30%,70%{translate:3px 0} 50%{translate:-4px 0} }
        .fli.is-error .fli-field { animation: fli-shake 0.4s both; }
        @media (prefers-reduced-motion: reduce) {
          .fli-label { transition: none; } .fli.is-error .fli-field { animation: none; }
        }
      `}</style>

      <div className="fli-field">
        <input id={id} className="fli-input" type={type} placeholder=" " aria-invalid={!!error} />
        <label className="fli-label" htmlFor={id}>
          {label}
        </label>
      </div>
      {(error || hint) && <div className="fli-msg">{error ?? hint}</div>}
      {error && (
        <button
          type="button"
          onClick={() => setShake((v) => v + 1)}
          style={{
            marginTop: 8,
            border: "none",
            background: "none",
            color: "var(--ds-muted, oklch(0.62 0.02 265))",
            font: "0.72rem var(--ds-font-mono, ui-monospace, monospace)",
            cursor: "pointer",
            padding: 0,
          }}
        >
          ↻ повторить встряску
        </button>
      )}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0