Перейти к содержимому

23. Переходы и анимации

Привет! 👋 Solid.js предоставляет несколько мощных способов анимировать интерфейс: от простого переключения CSS-классов до интеграции с браузерным View Transitions API и физически-реалистичными spring-анимациями.

Думай об анимациях в Solid как о слоях: сначала CSS (быстро, просто), потом transition:name для переходов между страницами, потом spring-физика для “живых” ощущений.


🎭 CSS переходы через переключение классов

Заголовок раздела «🎭 CSS переходы через переключение классов»

Самый простой способ — добавлять/убирать CSS-классы реактивно:

import { createSignal } from 'solid-js';
function AnimatedCard() {
const [visible, setVisible] = createSignal(false);
const [expanded, setExpanded] = createSignal(false);
return (
<>
<button onClick={() => setVisible(v => !v)}>
{visible() ? 'Скрыть' : 'Показать'}
</button>
{/* classList — реактивный объект классов */}
<div
classList={{
'card': true,
'card--visible': visible(),
'card--expanded': expanded(),
}}
onClick={() => setExpanded(e => !e)}
>
Нажми чтобы развернуть
</div>
</>
);
}
styles.css
.card {
opacity: 0;
transform: translateY(10px) scale(0.97);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.card--visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.card--expanded {
height: 200px;
transition: height 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); /* spring-like */
}

Solid.js поддерживает директиву transition:name для плавных переходов между DOM-узлами (браузерный View Transitions API):

import { useNavigate } from '@solidjs/router';
// Карточка товара — с именованным переходом
function ProductCard({ product }: { product: Product }) {
const navigate = useNavigate();
return (
<div
// transition:name привязывает элемент к именованному переходу
// При навигации браузер анимирует переход между двумя элементами с одинаковым именем
style={{ "view-transition-name": `product-${product.id}` }}
onClick={() => navigate(`/products/${product.id}`)}
>
<img
src={product.image}
style={{ "view-transition-name": `product-img-${product.id}` }}
/>
<h3>{product.name}</h3>
</div>
);
}
// Страница товара — тот же transition name = плавный переход!
function ProductPage({ id }: { id: number }) {
return (
<div style={{ "view-transition-name": `product-${id}` }}>
<img style={{ "view-transition-name": `product-img-${id}` }} />
{/* Содержимое страницы */}
</div>
);
}
// router.tsx — включаем View Transitions
import { Router } from '@solidjs/router';
// View Transitions включаются автоматически в @solidjs/router v0.12+
// Или вручную:
function AppRouter() {
return (
<Router
// Включить View Transitions для навигации
explicitLinks={false}
>
{/* роуты */}
</Router>
);
}

Для анимации добавления/удаления элементов из списков:

import { createSignal, For } from 'solid-js';
import { TransitionGroup } from '@solid-primitives/transition-group';
function AnimatedList() {
const [items, setItems] = createSignal(['Яблоко', 'Банан', 'Вишня']);
const addItem = () => {
const fruits = ['Манго', 'Клубника', 'Черника', 'Груша', 'Апельсин'];
const random = fruits[Math.floor(Math.random() * fruits.length)];
setItems(prev => [...prev, random]);
};
const removeItem = (name: string) => {
setItems(prev => prev.filter(i => i !== name));
};
return (
<div>
<button onClick={addItem}>+ Добавить фрукт</button>
<TransitionGroup name="fade-slide">
<For each={items()}>
{(item) => (
<div class="list-item" onClick={() => removeItem(item)}>
{item} ✕
</div>
)}
</For>
</TransitionGroup>
</div>
);
}
/* Enter animation */
.fade-slide-enter-active,
.fade-slide-exit-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from,
.fade-slide-exit-to {
opacity: 0;
transform: translateX(-20px);
}
/* Move animation (для перегруппировки) */
.fade-slide-move {
transition: transform 0.3s ease;
}

import { createSignal } from 'solid-js';
// Из @solid-primitives/spring
import { createSpring } from '@solid-primitives/spring';
function SpringDemo() {
const [target, setTarget] = createSignal(0);
// spring автоматически интерполирует к target значению
const position = createSpring(target, {
stiffness: 0.15, // жёсткость пружины
damping: 0.8, // затухание
precision: 0.001,
});
return (
<div>
<div
style={{
transform: `translateX(${position()}px)`,
// Обновляется каждый кадр — плавно!
}}
>
🔵
</div>
<button onClick={() => setTarget(t => t === 0 ? 200 : 0)}>
Анимировать
</button>
</div>
);
}
// createTween — линейная интерполяция с easing
import { createTween } from '@solid-primitives/tween';
function TweenDemo() {
const [target, setTarget] = createSignal(100);
// linear tween за 500ms
const animated = createTween(target, { duration: 500, easing: t => t * t });
return (
<div>
<div style={{ width: `${animated()}px`, height: '20px', background: 'blue' }} />
<button onClick={() => setTarget(t => t === 100 ? 300 : 100)}>
Анимировать ширину
</button>
</div>
);
}

import { createSignal } from 'solid-js';
import { motion } from '@motionone/solid';
function MotionDemo() {
const [open, setOpen] = createSignal(false);
return (
<>
{/* motion.div — декларативные анимации */}
<motion.div
animate={{
rotate: open() ? 180 : 0,
scale: open() ? 1.2 : 1,
}}
transition={{ duration: 0.3, easing: 'ease-in-out' }}
>
</motion.div>
{/* Анимация появления/исчезновения */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: open() ? 1 : 0, y: open() ? 0 : -10 }}
transition={{ duration: 0.2 }}
>
Содержимое раскрывающегося блока
</motion.div>
<button onClick={() => setOpen(o => !o)}>Раскрыть</button>
</>
);
}
// Stagger: анимация группы элементов с задержкой
import { inView, animate, stagger } from 'motion';
import { onMount } from 'solid-js';
function StaggerList() {
let listRef!: HTMLUListElement;
onMount(() => {
// Анимируем все li с stagger
animate(
listRef.querySelectorAll('li'),
{ opacity: [0, 1], y: [20, 0] },
{ delay: stagger(0.1), duration: 0.4 }
);
});
return (
<ul ref={listRef}>
<li>Элемент 1</li>
<li>Элемент 2</li>
<li>Элемент 3</li>
</ul>
);
}

// ❌ height: auto — не анимируется в CSS!
// transition: height 0.3s — не работает с auto
.accordion {
height: 0;
/* height: auto — нет! */
}
// ✅ Используй max-height или clip-path
.accordion {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.accordion--open {
max-height: 500px; /* немного больше максимально возможной высоты */
}
// ❌ animation вместо transition для реактивных изменений
// animation запускается один раз, transition следит за изменениями
// ✅ Для "trigger on click" используй animation + class toggle
.card {
animation: none;
}
.card--bounce {
animation: bounce 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) forwards;
}
// ❌ Анимировать layout properties (width, height, top, left)
// Это вызывает layout reflow — дорого!
// ✅ Анимируй только transform и opacity
// transform: translateX/Y/Z, scale, rotate — GPU-ускоренные!