Focus List
Список с линзой: строка у середины кадра — крупная и яркая, к кромкам сжимается и гаснет, фокус непрерывный и отматывается вместе с прокруткой. Ноль скриптов: у каждой строки свой animation-timeline: view(), линзу дают симметричные ключевые кадры. Наблюдателем такое не сделать — он знает «видно/не видно», здесь важно «насколько близко к центру».
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | FocusListItem[] | ITEMS | Строки списка. |
| fontSize? | string | "clamp(1.8rem, 6cqw, 3.4rem)" | Кегль строк: любое CSS-значение font-size. |
FocusListItem — что внутри
- label: string— Крупная строка.
- note?: string— Мелкая приписка справа (моноширинная).
Установка
npx shadcn@latest add https://motiva.pages.dev/r/focus-list.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.
Экспорт
Код
.fcl { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.1em;
font-family: var(--ds-font, system-ui, sans-serif); }
/* база — обычный читаемый список: без поддержки прокруточных анимаций,
под prefers-reduced-motion и data-shot линзы просто нет */
.fcl-row { margin: 0; padding: 0.08em 0;
font-weight: 700; line-height: 1.06;
letter-spacing: var(--ds-tracking-tight, -0.03em);
color: var(--ds-fg, oklch(0.95 0.01 265));
transform-origin: left center; }
.fcl-row > small { margin-left: 0.6em;
font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: 0.32em; font-weight: 400; letter-spacing: 0.14em; vertical-align: middle;
color: var(--ds-muted, oklch(0.72 0.02 265)); }
@supports (animation-timeline: view()) {
@media not (prefers-reduced-motion: reduce) {
/* линза: у КАЖДОЙ строки свой view()-таймлайн, и симметричные кейфреймы дают
непрерывный фокус — строка крупная и яркая ровно у середины кадра, к кромкам
сжимается и гаснет. Прогресс отматывается вместе с прокруткой; наблюдателем
такое не сделать — он знает только «видно/не видно», а не «насколько» */
.fcl-row {
animation: fcl-focus linear both;
animation-timeline: view();
/* строка мала против кадра — contain честно доступен по всей длине пути */
animation-range: cover 0% cover 100%;
}
@keyframes fcl-focus {
0% { opacity: 0.14; scale: 0.8; translate: -0.35em 0; }
50% { opacity: 1; scale: 1; translate: 0 0; }
100% { opacity: 0.14; scale: 0.8; translate: -0.35em 0; }
}
}
}
html[data-shot] .fcl-row { animation: none; }
const css = `.fcl { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.1em;
font-family: var(--ds-font, system-ui, sans-serif); }
/* база — обычный читаемый список: без поддержки прокруточных анимаций,
под prefers-reduced-motion и data-shot линзы просто нет */
.fcl-row { margin: 0; padding: 0.08em 0;
font-weight: 700; line-height: 1.06;
letter-spacing: var(--ds-tracking-tight, -0.03em);
color: var(--ds-fg, oklch(0.95 0.01 265));
transform-origin: left center; }
.fcl-row > small { margin-left: 0.6em;
font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: 0.32em; font-weight: 400; letter-spacing: 0.14em; vertical-align: middle;
color: var(--ds-muted, oklch(0.72 0.02 265)); }
@supports (animation-timeline: view()) {
@media not (prefers-reduced-motion: reduce) {
/* линза: у КАЖДОЙ строки свой view()-таймлайн, и симметричные кейфреймы дают
непрерывный фокус — строка крупная и яркая ровно у середины кадра, к кромкам
сжимается и гаснет. Прогресс отматывается вместе с прокруткой; наблюдателем
такое не сделать — он знает только «видно/не видно», а не «насколько» */
.fcl-row {
animation: fcl-focus linear both;
animation-timeline: view();
/* строка мала против кадра — contain честно доступен по всей длине пути */
animation-range: cover 0% cover 100%;
}
@keyframes fcl-focus {
0% { opacity: 0.14; scale: 0.8; translate: -0.35em 0; }
50% { opacity: 1; scale: 1; translate: 0 0; }
100% { opacity: 0.14; scale: 0.8; translate: -0.35em 0; }
}
}
}
html[data-shot] .fcl-row { animation: none; }
`;
import type { CSSProperties } from "react";
export interface FocusListItem {
/** Крупная строка. */
label: string;
/** Мелкая приписка справа (моноширинная). */
note?: string;
}
export interface FocusListProps {
/** Строки списка. */
items?: FocusListItem[];
/** Кегль строк: любое CSS-значение font-size. */
fontSize?: string;
}
const ITEMS: FocusListItem[] = [
{ label: "Осло", note: "01" },
{ label: "Копенгаген", note: "02" },
{ label: "Гамбург", note: "03" },
{ label: "Роттердам", note: "04" },
{ label: "Антверпен", note: "05" },
{ label: "Лион", note: "06" },
{ label: "Марсель", note: "07" },
{ label: "Барселона", note: "08" },
{ label: "Валенсия", note: "09" },
{ label: "Лиссабон", note: "10" },
];
/**
* Список с линзой: строка у середины кадра — крупная и яркая, к кромкам строки
* сжимаются и гаснут. Фокус непрерывный и отматывается вместе с прокруткой.
*
* Ноль скриптов: у КАЖДОЙ строки свой таймлайн `animation-timeline: view()`,
* а «линзу» дают симметричные ключевые кадры (тусклая → яркая на 50% → тусклая).
* `IntersectionObserver` такого не умеет — он знает «видно/не видно», а здесь
* важно именно «насколько близко к центру».
*
* База — обычный читаемый список: без поддержки прокруточных анимаций, под
* `prefers-reduced-motion` и `data-shot` строки просто стоят в полный рост.
*/
export function FocusList({
items = ITEMS,
fontSize = "clamp(1.8rem, 6cqw, 3.4rem)",
}: FocusListProps = {}) {
return (
<ul className="fcl" style={{ fontSize } as CSSProperties}>
<style href="motiva-focus-list" precedence="medium">{css}</style>
{items.map((it) => (
<li className="fcl-row" key={it.label}>
{it.label}
{it.note && <small>{it.note}</small>}
</li>
))}
</ul>
);
}