← каталог
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Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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