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

Glare Card

Карточка-стекло: наклоняется за курсором, по поверхности едет продолговатый блик как по глянцевой печати. Ни твинов, ни rAF — только CSS-переменные.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
children?ReactNode—
tilt?number10Максимальный наклон, град..
glare?number0.35Сила блика 0–1.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/glare-card.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

Код

"use client";

const css = `.glc-wrap { perspective: 900px; }
.glc {
  --glc-rx: 0deg; --glc-ry: 0deg; --glc-x: 50%; --glc-y: 50%; --glc-op: 0;
  position: relative; overflow: hidden; isolation: isolate;
  width: min(300px, 78vw); aspect-ratio: 4 / 5; border-radius: 18px; padding: 20px;
  display: flex; flex-direction: column; justify-content: flex-end;
  border: 1px solid var(--ds-border, oklch(0.34 0.03 280));
  background: linear-gradient(160deg, oklch(0.28 0.06 290), oklch(0.16 0.03 275));
  box-shadow: 0 22px 50px oklch(0 0 0 / 0.45);
  transform: rotateX(var(--glc-rx)) rotateY(var(--glc-ry));
  transition: transform 0.35s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
  will-change: transform;
}
/* продолговатый блик — как отражение лампы на глянце */
.glc::after {
  content: ""; position: absolute; inset: -40%;
  background: radial-gradient(40% 22% at var(--glc-x) var(--glc-y),
    oklch(1 0 0 / 0.85), transparent 70%);
  opacity: var(--glc-op); transition: opacity 0.3s; pointer-events: none;
  mix-blend-mode: overlay;
}
.glc-title { font-size: 1.15rem; font-weight: 700; letter-spacing: -0.02em;
  color: var(--ds-fg, oklch(0.97 0.01 265)); }
.glc-sub { margin-top: 5px; font-family: var(--ds-font-mono, ui-monospace, monospace); font-size: 0.72rem;
  color: var(--ds-muted, oklch(0.75 0.03 280)); }
@media (prefers-reduced-motion: reduce) { .glc { transition: none; } }
`;
import { useRef, type ReactNode } from "react";

export interface GlareCardProps {
  children?: ReactNode;
  /** Максимальный наклон, град.. */
  tilt?: number;
  /** Сила блика 0–1. */
  glare?: number;
}

/**
 * Карточка-стекло: наклоняется за курсором, а по поверхности едет продолговатый блик,
 * как по глянцевой печати. Наклон и блик считаются из ОДНОГО обработчика и уезжают в
 * CSS-переменные — ни твинов, ни rAF-лупа; в покое элемент полностью статичен.
 */
export function GlareCard({ children, tilt = 10, glare = 0.35 }: GlareCardProps) {
  const ref = useRef<HTMLDivElement>(null);

  const frozen = () =>
    typeof document !== "undefined" &&
    (document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches);

  const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
    const el = ref.current;
    if (!el || frozen()) return;
    const r = el.getBoundingClientRect();
    const px = (e.clientX - r.left) / r.width;
    const py = (e.clientY - r.top) / r.height;
    el.style.setProperty("--glc-rx", `${(0.5 - py) * tilt * 2}deg`);
    el.style.setProperty("--glc-ry", `${(px - 0.5) * tilt * 2}deg`);
    el.style.setProperty("--glc-x", `${px * 100}%`);
    el.style.setProperty("--glc-y", `${py * 100}%`);
    el.style.setProperty("--glc-op", String(glare));
  };

  const reset = () => {
    const el = ref.current;
    if (!el) return;
    el.style.setProperty("--glc-rx", "0deg");
    el.style.setProperty("--glc-ry", "0deg");
    el.style.setProperty("--glc-op", "0");
  };

  return (
    <div className="glc-wrap">
      <style href="motiva-glare-card" precedence="medium">{css}</style>
      <div ref={ref} className="glc" onPointerMove={onMove} onPointerLeave={reset}>
        {children ?? (
          <>
            <div className="glc-title">Glare Card</div>
            <div className="glc-sub">наведи — блик поедет за курсором</div>
          </>
        )}
      </div>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0