Floating Label Input
Поле с плавающей подписью: подпись уезжает в вырез рамки. Состояние «непустое» ловится чистым CSS, поэтому подпись права и после автозаполнения браузером.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| label? | string | "Рабочая почта" | |
| type? | string | "email" | |
| hint? | string | — | Подсказка под полем. |
| error? | string | — | Текст ошибки — поле подсвечивается и коротко вздрагивает. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/floating-label-input.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
const css = `.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; }
}
`;
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 href="motiva-floating-label-input" precedence="medium">{css}</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>
);
}