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

Download Panel

Панель загрузки, которая подсвечивает подходящую сборку, но не прячет остальные: детект отвечает не «что показать», а «что выделить». Отметка появляется после первого кадра и место под неё занято заранее — координаты кнопок не сдвигаются.

cssloadhovermouse-moveintermediate0reduced-motion ✓

Редактируемые поля

Секция «download». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.

ПропЧто этоТип
eyebrowНадпись сверхуtext
titleЗаголовокtext
subОписаниеrichtext
buildsСборкиlist · до 6
versionВерсияtext
releasedДата сборкиtext
checksumКонтрольная суммаrichtext
allLabelВсе версии — подписьtext
allHrefВсе версии — адресlink

Все пропсы

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

ПропТипПо умолчаниюЧто это
eyebrow?string"загрузка"
title?string"Скачайте и попробуйте — регистрация не …
sub?string"Полная версия на 30 дней. Файлы подписа…
version?string"2.4.1"
released?string"26 июля 2026"Дата сборки как её показывать (формат «26 июля 2026»).
builds?Build[]BUILDS
matchLabel?string"похоже, ваша система"Подпись у подсвеченной сборки (формат «похоже, ваша система»).
checksum?string"sha256 4f3a91c0…7be2 — сверьте перед ус…Контрольная сумма и как её проверить.
allLabel?string"Все версии и старые сборки"Ссылка на все версии — обязательный выход, если детект промахнулся.
allHref?string"#"
background?ReactNodeСокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой.
Build — что внутри
  • label: string— Название сборки (формат «macOS»).
  • note?: string— Уточнение под названием (формат «Apple Silicon · 12+»).
  • size?: string— Размер файла (формат «104 МБ»).
  • href?: string
  • match?: Platform— Какой платформе соответствует: по этому полю сборка подсвечивается как «ваша».

Установка

npx shadcn@latest add https://motiva.pages.dev/r/download-panel.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";

export type Platform = "mac" | "win" | "linux";

export interface Build {
  /** Название сборки (формат «macOS»). */
  label: string;
  /** Уточнение под названием (формат «Apple Silicon · 12+»). */
  note?: string;
  /** Размер файла (формат «104 МБ»). */
  size?: string;
  href?: string;
  /** Какой платформе соответствует: по этому полю сборка подсвечивается как «ваша». */
  match?: Platform;
}

export interface DownloadPanelProps {
  eyebrow?: string;
  title?: string;
  sub?: string;
  version?: string;
  /** Дата сборки как её показывать (формат «26 июля 2026»). */
  released?: string;
  builds?: Build[];
  /** Подпись у подсвеченной сборки (формат «похоже, ваша система»). */
  matchLabel?: string;
  /** Контрольная сумма и как её проверить. */
  checksum?: string;
  /** Ссылка на все версии — обязательный выход, если детект промахнулся. */
  allLabel?: string;
  allHref?: string;
  /**
   * Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
   * Занят — секция снимает собственную заливку, чтобы не спорить за слой.
   */
  background?: ReactNode;
  /** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
  tone?: "dark" | "light";
  /** Плотность скрима между фоном и текстом (формат «0.5»); 0 — выключить. */
  scrim?: number;
}

const BUILDS: Build[] = [
  { label: "macOS", note: "Apple Silicon и Intel · 13+", size: "104 МБ", match: "mac" },
  { label: "Windows", note: "10 и 11 · x64", size: "96 МБ", match: "win" },
  { label: "Linux", note: "AppImage · x64 и ARM", size: "112 МБ", match: "linux" },
];

/**
 * Какая это система. Спрашиваем сперва `userAgentData` (там платформа лежит отдельным полем,
 * а не строкой, которую все подделывают), потом откатываемся на строку.
 *
 * Мобильные намеренно НЕ распознаются: в строке Android есть слово Linux, и наивная проверка
 * подсветит телефону сборку для десктопа. Нет подходящей — не подсвечиваем ничего,
 * это честнее, чем угадать неверно.
 */
const detect = (): Platform | null => {
  const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
  const s = `${nav.userAgentData?.platform ?? ""} ${navigator.userAgent}`.toLowerCase();
  if (/android|iphone|ipad|ipod/.test(s)) return null;
  if (/mac/.test(s)) return "mac";
  if (/win/.test(s)) return "win";
  if (/linux|x11|cros/.test(s)) return "linux";
  return null;
};

/**
 * Панель загрузки, которая ПОДСВЕЧИВАЕТ подходящую сборку, но не прячет остальные.
 *
 * Обычно делают наоборот: определяют систему и показывают одну кнопку. Это удобно ровно до
 * первого промаха — а промахи неизбежны. Человек скачивает на рабочий ноутбук сборку для
 * домашнего компьютера; за Windows-машиной сидят через удалённый рабочий стол; строку
 * агента подменяет корпоративный прокси; на планшете вообще ни одна из сборок не подходит.
 * Во всех этих случаях единственная кнопка — это тупик, из которого нет выхода на странице.
 * Поэтому здесь детект отвечает не «что показать», а всего лишь «что выделить»: остальные
 * сборки остаются на месте, и ссылка на полный список — тоже.
 *
 * Подсветка появляется ПОСЛЕ первого кадра: на сервере системы не знает никто, а гадать
 * в разметке значит отдать одному человеку чужой вариант из кеша. Место под отметку занято
 * заранее, поэтому её появление ничего не сдвигает — проверено замером: координаты кнопок
 * совпадают до знака и с подсветкой, и без неё.
 *
 * Отдельная грабля, про которую вспоминают поздно: атрибут `download` действует только
 * на СВОЁМ домене. Если файлы лежат на чужом CDN, браузер его игнорирует — имя файла
 * задаётся заголовком `Content-Disposition` на стороне раздачи, и никак иначе.
 */
export function DownloadPanel({
  eyebrow = "загрузка",
  title = "Скачайте и попробуйте — регистрация не нужна",
  sub = "Полная версия на 30 дней. Файлы подписаны, обновления приходят внутри приложения.",
  version = "2.4.1",
  released = "26 июля 2026",
  builds = BUILDS,
  matchLabel = "похоже, ваша система",
  checksum = "sha256 4f3a91c0…7be2 — сверьте перед установкой: shasum -a 256 <файл>",
  allLabel = "Все версии и старые сборки",
  allHref = "#",
  background,
  tone,
  scrim = 0.5,
}: DownloadPanelProps) {
  // не в ленивом инициализаторе: на сервере navigator нет, и первый кадр обязан быть
  // одинаковым для всех — иначе разметка из кеша достанется человеку с другой системой
  const [platform, setPlatform] = useState<Platform | null>(null);
  useEffect(() => setPlatform(detect()), []);

  /**
   * Блик под курсором на карточке сборки: один делегированный слушатель на список,
   * координаты пишутся в переменные той карточки, над которой указатель. Подсветка
   * «своей» системы при этом остаётся — она про выбор, а блик про наведение.
   */
  const list = useRef<HTMLUListElement>(null);
  useEffect(() => {
    const el = list.current;
    if (!el) return;
    const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (!fine || frozen) return;

    let hot: HTMLElement | null = null;
    let clientX = 0;
    let clientY = 0;
    let raf = 0;
    const frame = () => {
      raf = 0;
      if (!hot) return;
      const box = hot.getBoundingClientRect();
      hot.style.setProperty("--mx", `${(clientX - box.left).toFixed(0)}px`);
      hot.style.setProperty("--my", `${(clientY - box.top).toFixed(0)}px`);
    };
    const onMove = (e: PointerEvent) => {
      const card = (e.target as HTMLElement).closest<HTMLElement>(".dwn-card");
      if (card !== hot) {
        hot?.removeAttribute("data-hot");
        card?.setAttribute("data-hot", "");
        hot = card;
      }
      clientX = e.clientX;
      clientY = e.clientY;
      if (!raf) raf = requestAnimationFrame(frame);
    };
    const onLeave = () => {
      hot?.removeAttribute("data-hot");
      hot = null;
    };
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerleave", onLeave);
    return () => {
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerleave", onLeave);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [builds]);

  return (
    <section
      className="dwn"
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ textAlign: "start", ["--dwn-scrim" as string]: scrim }}
    >
      <style>{`
        .dwn { position: relative; isolation: isolate; width: 100%;
          padding-block: var(--ds-section-y, var(--ds-8, 80px));
          background: var(--ds-bg, oklch(0.14 0.02 275));
          color: var(--ds-fg, oklch(0.95 0.01 265));
          font-family: var(--ds-font, system-ui, sans-serif); }
        .dwn-in { max-width: var(--ds-container, 1120px); margin-inline: auto;
          padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px));
          display: grid; justify-items: center; text-align: center; }

        .dwn-eyebrow { margin: 0 0 var(--ds-3, 12px); text-transform: uppercase;
          font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.14em;
          color: var(--ds-accent, oklch(0.78 0.16 300)); }
        .dwn-h { margin: 0; max-width: 20ch; text-wrap: balance;
          font-size: var(--ds-text-xl, 2rem); font-weight: 780; line-height: 1.1;
          letter-spacing: var(--ds-tracking-tight, -0.03em); }
        .dwn-sub { margin: var(--ds-3, 12px) 0 0; max-width: 52ch; text-wrap: pretty;
          font-size: var(--ds-text-md, 1rem); line-height: var(--ds-leading-body, 1.55);
          color: var(--ds-muted, oklch(0.72 0.02 265)); }

        .dwn-list { margin: var(--ds-6, 36px) 0 0; padding: 0; list-style: none; width: 100%;
          display: grid; gap: var(--ds-3, 12px);
          grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); }

        .dwn-card { position: relative; isolation: isolate;
          display: grid; gap: 4px; padding: var(--ds-4, 18px); overflow: hidden;
          border-radius: var(--ds-radius, 12px); text-align: left; text-decoration: none;
          color: inherit; background: var(--ds-surface, oklch(0.18 0.02 275));
          border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
          transition: border-color var(--ds-fast, 0.18s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
                      translate var(--ds-fast, 0.18s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .dwn-card:is(:hover, :focus-visible) { translate: 0 -2px;
          border-color: var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.35)); }
        .dwn-card:focus-visible { outline: 2px solid var(--ds-accent, oklch(0.78 0.16 300));
          outline-offset: 3px; }
        /* «Ваша» сборка выделена рамкой и подложкой — но соседние никуда не делись */
        .dwn-card[data-match] { border-color: var(--ds-accent, oklch(0.78 0.16 300));
          background: var(--ds-accent-soft, oklch(0.72 0.16 300 / 0.1)); }

        /* блик под курсором: под содержимым карточки, поверх её заливки */
        .dwn-card::before { content: ""; position: absolute; inset: 0; z-index: -1;
          pointer-events: none; opacity: 0;
          background: radial-gradient(200px circle at var(--mx, 50%) var(--my, 50%),
            var(--ds-accent-soft, oklch(0.72 0.16 300 / 0.1)), transparent 70%);
          transition: opacity var(--ds-base, 0.32s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .dwn-card[data-hot]::before { opacity: 1; }

        .dwn-top { display: flex; align-items: baseline; gap: 8px; }
        .dwn-label { font-size: var(--ds-text-lg, 1.25rem); font-weight: 680;
          letter-spacing: var(--ds-tracking-tight, -0.03em); }
        .dwn-size { margin-left: auto; font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.75rem); font-variant-numeric: tabular-nums;
          color: var(--ds-muted, oklch(0.72 0.02 265)); }
        .dwn-note { font-size: var(--ds-text-xs, 0.75rem); line-height: 1.5;
          color: var(--ds-muted, oklch(0.72 0.02 265)); }

        /* Отметка «ваша система» ЗАНИМАЕТ МЕСТО ВСЕГДА: она появляется после первого кадра,
           и без резерва её появление сдвигало бы всю колонку карточек. */
        .dwn-badge { margin-top: 6px; min-height: 1lh; opacity: 0;
          font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.03em;
          color: var(--ds-accent, oklch(0.78 0.16 300));
          transition: opacity var(--ds-base, 0.32s) linear; }
        .dwn-card[data-match] .dwn-badge { opacity: 1; }

        .dwn-meta { margin: var(--ds-4, 18px) 0 0;
          font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.75rem); line-height: 1.7;
          color: var(--ds-muted, oklch(0.72 0.02 265)); }
        .dwn-meta b { font-weight: 600; color: var(--ds-fg, oklch(0.95 0.01 265)); }
        .dwn-all { display: inline-block; margin-top: 6px; font-size: var(--ds-text-sm, 0.86rem);
          color: var(--ds-accent, oklch(0.78 0.16 300));
          border-bottom: 1px solid var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.35));
          text-decoration: none; }
        .dwn-all:is(:hover, :focus-visible) { border-bottom-color: var(--ds-accent, oklch(0.78 0.16 300)); }

        /* СОКЕТ ФОНА. Слой на z-index -1 внутри своего stacking context: поверх заливки
           секции, но под содержимым — вёрстке карточек знать о нём не нужно. */
        .dwn-bg { position: absolute; inset: 0; z-index: -1; }
        .dwn[data-slot-bg] { background: transparent; }
        .dwn-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
        .dwn[data-tone="dark"] { --ds-fg: oklch(0.96 0.01 265); --ds-muted: oklch(0.84 0.02 265);
          --ds-border: oklch(0.98 0.01 265 / 0.16); --ds-surface: oklch(0.16 0.02 275 / 0.66);
          color: var(--ds-fg); }
        .dwn[data-tone="dark"] .dwn-bg::after {
          background: oklch(0.09 0.02 265 / calc(var(--dwn-scrim, 0.5) * 0.9)); }
        .dwn[data-tone="light"] { --ds-fg: oklch(0.2 0.02 265); --ds-muted: oklch(0.42 0.02 265);
          --ds-border: oklch(0.2 0.02 265 / 0.14); --ds-surface: oklch(1 0 0 / 0.72);
          color: var(--ds-fg); }
        .dwn[data-tone="light"] .dwn-bg::after {
          background: oklch(0.99 0 0 / calc(var(--dwn-scrim, 0.5) * 0.9)); }

        @media (prefers-reduced-motion: reduce) {
          .dwn-card, .dwn-badge { transition: none; }
          .dwn-card::before { display: none; }
        }
      `}</style>

      {background && <div className="dwn-bg">{background}</div>}

      <div className="dwn-in">
        {eyebrow && <p className="dwn-eyebrow">{eyebrow}</p>}
        <h2 className="dwn-h">{title}</h2>
        {sub && <p className="dwn-sub">{sub}</p>}

        <ul className="dwn-list" ref={list}>
          {builds.map((b) => {
            const mine = !!b.match && b.match === platform;
            return (
              <li key={b.label}>
                <a
                  className="dwn-card"
                  href={b.href ?? "#"}
                  download
                  data-match={mine ? "" : undefined}
                >
                  <span className="dwn-top">
                    <span className="dwn-label">{b.label}</span>
                    {b.size && <span className="dwn-size">{b.size}</span>}
                  </span>
                  {b.note && <span className="dwn-note">{b.note}</span>}
                  <span className="dwn-badge" aria-hidden={!mine}>
                    {mine ? matchLabel : ""}
                  </span>
                </a>
              </li>
            );
          })}
        </ul>

        <p className="dwn-meta">
          Версия <b>{version}</b> · {released}
          <br />
          {checksum}
          <br />
          <a className="dwn-all" href={allHref}>
            {allLabel}
          </a>
        </p>
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0