← каталог
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Что нужно в проекте до установки
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 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