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

Theme Toggle Morph

Переключатель темы: солнце превращается в луну ОДНОЙ фигурой — круг выгрызается вторым кругом через SVG-маску, лучи расходятся и гаснут. Не подмена иконок, а морф.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
defaultDark?booleanfalseТёмная тема включена изначально.
onChange?(dark: boolean) => void;

Установка

npx shadcn@latest add https://motiva.pages.dev/r/theme-toggle-morph.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 { useState } from "react";

export interface ThemeToggleMorphProps {
  /** Тёмная тема включена изначально. */
  defaultDark?: boolean;
  onChange?: (dark: boolean) => void;
}

/**
 * Переключатель темы: солнце превращается в луну одной фигурой — круг «выгрызается»
 * вторым кругом через SVG-маску, и полумесяц получается сам. Лучи расходятся врозь
 * и гаснут. Никаких двух иконок с кроссфейдом — это ОДИН объект, поэтому морф читается
 * как трансформация, а не как подмена.
 */
export function ThemeToggleMorph({ defaultDark = false, onChange }: ThemeToggleMorphProps) {
  const [dark, setDark] = useState(defaultDark);

  const toggle = () => {
    const next = !dark;
    setDark(next);
    onChange?.(next);
  };

  return (
    <button className={`ttm${dark ? " is-dark" : ""}`} onClick={toggle} aria-pressed={dark} aria-label="Сменить тему">
      <style>{`
        .ttm { --ttm-fg: oklch(0.75 0.16 75); --ttm-bg: oklch(0.94 0.02 85);
          position: relative; width: 62px; height: 34px; padding: 0; cursor: pointer;
          border-radius: 999px; border: 1px solid oklch(0.8 0.03 85);
          background: var(--ttm-bg); transition: background 0.45s, border-color 0.45s;
          display: grid; place-items: center; }
        .ttm.is-dark { --ttm-fg: oklch(0.9 0.02 265); --ttm-bg: oklch(0.24 0.03 275);
          border-color: oklch(0.36 0.03 275); }
        .ttm:focus-visible { outline: 2px solid var(--ds-accent, oklch(0.72 0.15 300)); outline-offset: 3px; }

        .ttm-knob { position: absolute; top: 3px; left: 3px; width: 26px; height: 26px;
          border-radius: 50%; background: oklch(1 0 0 / 0.06);
          transition: translate 0.45s cubic-bezier(0.34, 1.4, 0.64, 1); }
        .ttm.is-dark .ttm-knob { translate: 28px 0; }

        .ttm-svg { position: absolute; top: 5px; left: 5px; width: 22px; height: 22px;
          overflow: visible; color: var(--ttm-fg);
          transition: translate 0.45s cubic-bezier(0.34, 1.4, 0.64, 1), rotate 0.6s ease; }
        .ttm.is-dark .ttm-svg { translate: 28px 0; rotate: -35deg; }

        /* маска: чёрный круг «выедает» из белого диска полумесяц */
        .ttm-cut { transition: cx 0.5s var(--ds-ease-in-out, cubic-bezier(0.65,0,0.35,1)), cy 0.5s var(--ds-ease-in-out, cubic-bezier(0.65,0,0.35,1)); }
        .ttm-rays { transition: opacity 0.3s, scale 0.45s var(--ds-ease-spring, cubic-bezier(0.34,1.4,0.64,1));
          transform-origin: 12px 12px; }
        .ttm.is-dark .ttm-rays { opacity: 0; scale: 0.4; }
        .ttm-disc { transition: r 0.45s var(--ds-ease-spring, cubic-bezier(0.34,1.4,0.64,1)); }
        @media (prefers-reduced-motion: reduce) {
          .ttm, .ttm-knob, .ttm-svg, .ttm-cut, .ttm-rays, .ttm-disc { transition: none; }
        }
      `}</style>

      <span className="ttm-knob" />
      <svg className="ttm-svg" viewBox="0 0 24 24" aria-hidden>
        <mask id="ttm-mask">
          <rect width="24" height="24" fill="#fff" />
          {/* этот круг съезжает на диск и вырезает полумесяц */}
          <circle className="ttm-cut" cx={dark ? 16 : 30} cy={dark ? 6 : -6} r="9" fill="#000" />
        </mask>
        <circle
          className="ttm-disc"
          cx="12"
          cy="12"
          r={dark ? 8.5 : 5.5}
          fill="currentColor"
          mask="url(#ttm-mask)"
        />
        <g className="ttm-rays" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
          <path d="M12 1.6v2.4M12 20v2.4M4.2 4.2l1.7 1.7M18.1 18.1l1.7 1.7M1.6 12h2.4M20 12h2.4M4.2 19.8l1.7-1.7M18.1 5.9l1.7-1.7" />
        </g>
      </svg>
    </button>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0