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

Error State

Страница «не найдено», которая угадывает нужный адрес по расстоянию редактирования. Подстрока тут не работает в принципе: /pricng не содержит /pricing и не содержится в нём. Считается на клиенте, без запроса к серверу — 404 часто отдаётся статикой.

cssloadintermediate0reduced-motion ✓

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

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

ПропЧто этоТип
codeКод ошибкиtext
titleЗаголовокtext
subОписаниеrichtext
routesКуда можно пойтиlist · до 10
suggestPrefixНачало подсказкиtext
homeLabelКнопка на главную — подписьtext
homeHrefКнопка на главную — адресlink
backLabelКнопка назад — подписьtext

Все пропсы

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

ПропТипПо умолчаниюЧто это
code?string"404"
title?string"Такой страницы нет"
sub?string"Возможно, адрес набран с опечаткой или …
path?stringАдрес, которого нет. Не задан — берётся из строки браузера после первого кадра.
routes?Route[]ROUTESКуда вообще можно пойти. Этот же список — материал для подсказки.
suggestPrefix?string"Возможно, вы имели в виду"Начало фразы с подсказкой (формат «Возможно, вы имели в виду»).
backLabel?string"Вернуться назад"
homeLabel?string"На главную"
homeHref?string"/"
background?ReactNodeСокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой.
Route — что внутри
  • path: string
  • label: string

Установка

npx shadcn@latest add https://motiva.pages.dev/r/error-state.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, useMemo, useState } from "react";

export interface Route {
  path: string;
  label: string;
}

export interface ErrorStateProps {
  code?: string;
  title?: string;
  sub?: string;
  /** Адрес, которого нет. Не задан — берётся из строки браузера после первого кадра. */
  path?: string;
  /** Куда вообще можно пойти. Этот же список — материал для подсказки. */
  routes?: Route[];
  /** Начало фразы с подсказкой (формат «Возможно, вы имели в виду»). */
  suggestPrefix?: string;
  backLabel?: string;
  homeLabel?: string;
  homeHref?: string;
  /**
   * Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
   * Занят — секция снимает собственную заливку, чтобы не спорить за слой.
   */
  background?: ReactNode;
  /** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
  tone?: "dark" | "light";
  /** Плотность скрима между фоном и текстом (формат «0.5»); 0 — выключить. */
  scrim?: number;
}

const ROUTES: Route[] = [
  { path: "/pricing", label: "Тарифы" },
  { path: "/docs", label: "Документация" },
  { path: "/changelog", label: "Что нового" },
  { path: "/blog", label: "Журнал" },
  { path: "/download", label: "Загрузка" },
  { path: "/contacts", label: "Контакты" },
  { path: "/careers", label: "Вакансии" },
];

/** Регистр, лишние слэши и разделители смысла не несут — сравнивать надо без них. */
const norm = (p: string) =>
  p.toLowerCase().replace(/^\/+|\/+$/g, "").replace(/[_\s]+/g, "-");

/**
 * Расстояние редактирования: сколько правок отделяет одну строку от другой.
 *
 * Матрица целиком не нужна — при подсчёте очередной строки видна только предыдущая, поэтому
 * в памяти живут два ряда вместо n×m. На десятке маршрутов разницы не заметить, но за эту
 * функцию хватаются в местах погорячее (поиск по мере ввода), и там она уже принципиальна.
 */
const distance = (a: string, b: string): number => {
  if (a === b) return 0;
  if (!a.length || !b.length) return a.length + b.length;
  // Int32Array, а не обычный массив: буфер фиксированной длины из чисел, без дыр и боксинга
  let prev = new Int32Array(b.length + 1);
  let cur = new Int32Array(b.length + 1);
  for (let j = 0; j <= b.length; j++) prev[j] = j;
  for (let i = 1; i <= a.length; i++) {
    cur[0] = i;
    const ai = a[i - 1];
    for (let j = 1; j <= b.length; j++) {
      // `?? 0` недостижимы (индексы заведомо внутри буфера), но их требует
      // noUncheckedIndexedAccess — заодно у трёх слагаемых появились имена
      const up = prev[j] ?? 0; // удалили знак
      const left = cur[j - 1] ?? 0; // вставили знак
      const diag = prev[j - 1] ?? 0; // заменили или совпало
      cur[j] = Math.min(up + 1, left + 1, diag + (ai === b[j - 1] ? 0 : 1));
    }
    [prev, cur] = [cur, prev]; // ряды меняются местами, новых массивов не создаём
  }
  return prev[b.length] ?? 0;
};

/** Ближайший маршрут — или ничего, если ближайший всё равно слишком далёк. */
const closest = (path: string, routes: Route[]): Route | null => {
  const want = norm(path);
  if (!want) return null;
  const tail = want.split("/").pop() ?? want;
  let best: Route | null = null;
  let bestD = Infinity;
  for (const r of routes) {
    const full = norm(r.path);
    // и по всему пути, и по последнему сегменту: /docs/pricng ближе к /pricing по хвосту
    const d = Math.min(distance(want, full), distance(tail, full.split("/").pop() ?? full));
    if (d < bestD) {
      bestD = d;
      best = r;
    }
  }
  // Порог. Без него на любой мусор выдаётся случайная ссылка, и подсказке перестают верить:
  // одна невпопад стоит дороже, чем десять несделанных.
  const limit = Math.max(1, Math.floor(Math.max(want.length, norm(best?.path ?? "").length) / 3));
  return best && bestD <= limit ? best : null;
};

/**
 * Страница «не найдено», которая пытается угадать, куда человек шёл.
 *
 * Опечатка в адресе — самая частая причина 404 после протухшей ссылки, и почти всегда
 * верный адрес отличается от набранного на один-два знака. Подсказку обычно либо не делают
 * вовсе, либо ищут подстроку — а подстрока тут не работает в принципе: `/pricng` не содержит
 * `/pricing` и не содержится в нём, у них просто нет общего куска нужной длины. Работает
 * расстояние редактирования: «сколько правок отделяет одно от другого». Считается на клиенте
 * за микросекунды, никакого запроса к серверу — что важно, потому что 404 часто отдаётся
 * статикой, где спрашивать некого.
 *
 * Адрес читается ПОСЛЕ первого кадра: на сервере статическая страница ошибки одна на всех,
 * и подставить в разметку чей-то путь значит отдать его следующему посетителю из кеша.
 *
 * Кнопка «назад» показывается, только если есть куда возвращаться — если человек пришёл
 * с нашего же сайта. Без этой проверки кнопка на треть заходов (из поиска, из мессенджера)
 * просто ничего не делает, а неработающая кнопка хуже отсутствующей.
 */
export function ErrorState({
  code = "404",
  title = "Такой страницы нет",
  sub = "Возможно, адрес набран с опечаткой или страницу перенесли.",
  path,
  routes = ROUTES,
  suggestPrefix = "Возможно, вы имели в виду",
  backLabel = "Вернуться назад",
  homeLabel = "На главную",
  homeHref = "/",
  background,
  tone,
  scrim = 0.5,
}: ErrorStateProps) {
  // адрес и наличие реферера известны только браузеру — читаем после первого кадра
  const [auto, setAuto] = useState("");
  const [canBack, setCanBack] = useState(false);
  useEffect(() => {
    setAuto(location.pathname);
    try {
      setCanBack(!!document.referrer && new URL(document.referrer).origin === location.origin);
    } catch {
      setCanBack(false);
    }
  }, []);

  const current = path ?? auto;
  const guess = useMemo(() => closest(current, routes), [current, routes]);

  return (
    <section
      className="err"
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ textAlign: "start", ["--err-scrim" as string]: scrim }}
    >
      <style>{`
        .err { 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); }
        .err-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; }

        .err-code { margin: 0; font-size: clamp(3.5rem, 14vw, 7rem); font-weight: 820;
          line-height: 0.9; letter-spacing: var(--ds-tracking-display, -0.05em);
          font-variant-numeric: tabular-nums;
          background: linear-gradient(180deg,
            var(--ds-accent, oklch(0.78 0.16 300)), transparent 190%);
          -webkit-background-clip: text; background-clip: text; color: transparent; }
        .err-h { margin: var(--ds-3, 12px) 0 0; max-width: 18ch; 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); }
        .err-sub { margin: var(--ds-3, 12px) 0 0; max-width: 46ch; 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)); }

        /* Место под подсказку занято всегда: она появляется после первого кадра, и без
           резерва кнопки под ней прыгали бы в момент, когда по ним уже целятся. */
        .err-guess { margin: var(--ds-5, 24px) 0 0; min-height: 2lh; max-width: 52ch;
          display: grid; align-content: center; gap: 4px;
          font-size: var(--ds-text-sm, 0.86rem); line-height: 1.5;
          color: var(--ds-muted, oklch(0.72 0.02 265)); }
        .err-typed { font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.75rem); word-break: break-all;
          color: var(--ds-muted, oklch(0.72 0.02 265)); opacity: 0.8; }
        .err-typed s { text-decoration-color: var(--ds-danger, oklch(0.63 0.2 25)); }
        .err-fix { font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-weight: 600; text-decoration: none;
          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)); }
        .err-fix:is(:hover, :focus-visible) { border-bottom-color: var(--ds-accent, oklch(0.78 0.16 300)); }

        .err-acts { margin: var(--ds-5, 24px) 0 0; display: flex; gap: var(--ds-2, 8px);
          flex-wrap: wrap; justify-content: center; }
        .err-btn { padding: 11px 20px; font: inherit; font-size: var(--ds-text-sm, 0.86rem);
          font-weight: 620; border-radius: var(--ds-radius, 12px); cursor: pointer;
          text-decoration: none; border: 1px solid transparent;
          background: var(--ds-accent, oklch(0.78 0.16 300));
          color: var(--ds-accent-fg, oklch(0.16 0.02 275));
          transition: background var(--ds-fast, 0.18s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .err-btn:is(:hover, :focus-visible) { background: var(--ds-accent-hover, oklch(0.82 0.16 300)); }
        .err-btn.err-ghost { background: transparent; color: var(--ds-fg, oklch(0.95 0.01 265));
          border-color: var(--ds-border, oklch(0.3 0.02 275)); }
        .err-btn.err-ghost:is(:hover, :focus-visible) {
          border-color: var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.35)); background: transparent; }

        .err-map { margin: var(--ds-6, 36px) 0 0; padding: 0; list-style: none;
          display: flex; flex-wrap: wrap; gap: 8px 18px; justify-content: center;
          font-size: var(--ds-text-sm, 0.86rem); }
        .err-map a { text-decoration: none; color: var(--ds-muted, oklch(0.72 0.02 265));
          transition: color var(--ds-fast, 0.18s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .err-map a:is(:hover, :focus-visible) { color: var(--ds-fg, oklch(0.95 0.01 265)); }

        /* СОКЕТ ФОНА. Слой на z-index -1 внутри своего stacking context: поверх заливки
           секции, но под содержимым — вёрстке экрана ошибки знать о нём не нужно. */
        .err-bg { position: absolute; inset: 0; z-index: -1; }
        .err[data-slot-bg] { background: transparent; }
        .err-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
        .err[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); color: var(--ds-fg); }
        .err[data-tone="dark"] .err-bg::after {
          background: oklch(0.09 0.02 265 / calc(var(--err-scrim, 0.5) * 0.9)); }
        .err[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); color: var(--ds-fg); }
        .err[data-tone="light"] .err-bg::after {
          background: oklch(0.99 0 0 / calc(var(--err-scrim, 0.5) * 0.9)); }

        @media (prefers-reduced-motion: reduce) {
          .err-btn, .err-map a { transition: none; }
        }
      `}</style>

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

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

        <div className="err-guess" data-guess={guess ? "" : undefined}>
          {current && (
            <span className="err-typed">
              <s>{current}</s>
            </span>
          )}
          {guess && (
            <span>
              {suggestPrefix}{" "}
              <a className="err-fix" href={guess.path}>
                {guess.path}
              </a>{" "}
{guess.label}?
            </span>
          )}
        </div>

        <div className="err-acts">
          <a className="err-btn" href={homeHref}>
            {homeLabel}
          </a>
          {canBack && (
            <button className="err-btn err-ghost" type="button" onClick={() => history.back()}>
              {backLabel}
            </button>
          )}
        </div>

        <ul className="err-map">
          {routes.map((r) => (
            <li key={r.path}>
              <a href={r.path}>{r.label}</a>
            </li>
          ))}
        </ul>
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0