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

Meteors

Метеоры: светящиеся росчерки падают по диагонали, каждый со своей задержкой и скоростью. Чистый CSS; позиции сеяно-случайны — без рассинхрона гидрации.

cssautobasicreduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
backdrop?string | falseBACKDROPПодложка (небо): CSS-значение `background` или `false`. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она.
count?number10Сколько метеоров в небе.
speed?number1Множитель скорости (1 — базовая).
hue?number265Тон хвоста, градусы oklch.
children?ReactNode—Содержимое поверх. В витрине — подпись, в секции обычно пусто.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/meteors.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

const css = `/* размерный контейнер: пролёт меряется в РОДИТЕЛЕ (cq-единицы), не во вьюпорте */
.mt-sky { container-type: size; }
.mt {
  position: absolute;
  width: 2px; height: 2px; border-radius: 999px;
  background: oklch(0.95 0.02 265 / 0.95);
  box-shadow: 0 0 6px 1px oklch(0.9 0.04 265 / 0.55);
  opacity: 0; /* до старта итерации и при data-shot метеора не видно */
  animation: mt-fly var(--mt-dur) linear infinite;
  animation-delay: var(--mt-delay);
}
/* хвост тянется ПРОТИВ движения (в минус-X локальной оси) */
.mt::before {
  content: "";
  position: absolute;
  top: 50%; right: 1px;
  width: 90px; height: 1px;
  transform: translateY(-50%);
  background: linear-gradient(270deg, var(--mt-tail), transparent);
}
/* 35 градусов вниз-вправо; дистанция в cqmax покрывает контейнер любой пропорции */
@keyframes mt-fly {
  0%   { transform: rotate(35deg) scale(var(--mt-scale)) translateX(0); opacity: 0; }
  6%   { opacity: 1; }
  80%  { opacity: 1; }
  100% { transform: rotate(35deg) scale(var(--mt-scale)) translateX(150cqmax); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .mt { display: none; } }
`;
import type { CSSProperties, ReactNode } from "react";

/** Подложка по умолчанию — как в витрине. */
const BACKDROP = "linear-gradient(180deg, oklch(0.17 0.02 270), oklch(0.12 0.015 270))";

/**
 * Сеяный генератор (mulberry32): позиции метеоров «случайны», но детерминированы.
 * Настоящий Math.random() дал бы разные значения на сервере и на клиенте —
 * и React ругался бы на рассинхрон гидрации при каждом открытии.
 */
function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export interface MeteorsProps {
  /**
   * Подложка (небо): CSS-значение `background` или `false`.
   * `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она.
   */
  backdrop?: string | false;
  /** Сколько метеоров в небе. */
  count?: number;
  /** Множитель скорости (1 — базовая). */
  speed?: number;
  /** Тон хвоста, градусы oklch. */
  hue?: number;
  /** Содержимое поверх. В витрине — подпись, в секции обычно пусто. */
  children?: ReactNode;
}

/**
 * Метеоры: росчерки падают по диагонали, каждый со своей задержкой и скоростью.
 * Чистый CSS: голова — точка со свечением, хвост — псевдоэлемент-градиент,
 * полёт — один transform. Отрицательные задержки заселяют небо сразу, без «пустого старта».
 * Reduced-motion — метеоры скрыты, остаётся спокойное небо.
 */
export function Meteors({ backdrop = BACKDROP, count = 10, speed = 1, hue = 265, children }: MeteorsProps = {}) {
  const rand = mulberry32(1042);
  const items = Array.from({ length: Math.max(1, Math.round(count)) }, () => {
    const dur = (4 + rand() * 5) / Math.max(0.2, speed); // 4–9 c на пролёт до множителя
    return {
      left: -15 + rand() * 100, // cqw: часть стартует за левой кромкой
      top: -25 + rand() * 55, // cqh: часть выше кромки, часть сразу в кадре
      dur,
      delay: -rand() * dur, // отрицательная задержка: метеор уже в полёте на первом кадре
      scale: 0.7 + rand() * 0.6,
    };
  });
  const tail = `oklch(0.82 0.05 ${hue})`;
  return (
    <div
      className="mt-sky"
      style={{
        position: "absolute",
        inset: 0,
        overflow: "hidden",
        ...(backdrop === false ? null : { background: backdrop }),
      }}
    >
      <style href="motiva-meteors" precedence="medium">{css}</style>
      {items.map((m, i) => (
        <span
          key={i}
          className="mt"
          style={
            {
              left: `${m.left.toFixed(2)}cqw`,
              top: `${m.top.toFixed(2)}cqh`,
              "--mt-dur": `${m.dur.toFixed(2)}s`,
              "--mt-delay": `${m.delay.toFixed(2)}s`,
              "--mt-scale": m.scale.toFixed(2),
              "--mt-tail": tail,
            } as CSSProperties
          }
        />
      ))}
      {children && (
        <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>{children}</div>
      )}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0