Theme Toggle Morph
Переключатель темы: солнце превращается в луну ОДНОЙ фигурой — круг выгрызается вторым кругом через SVG-маску, лучи расходятся и гаснут. Не подмена иконок, а морф.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| defaultDark? | boolean | false | Тёмная тема включена изначально. |
| onChange? | (dark: boolean) => void; | — |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/theme-toggle-morph.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
const css = `.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; }
}
`;
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 href="motiva-theme-toggle-morph" precedence="medium">{css}</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>
);
}