Ink Transition Effect (Modern Edition) — это переработанная версия классического эффекта перехода с анимацией растекающихся чернил, который открывает модальное окно при клике на кнопку. В новой версии полностью удалена зависимость от jQuery, код переписан на чистом JavaScript (Vanilla JS) с использованием современных стандартов ES6+, улучшена доступность (a11y), добавлена поддержка адаптивности и уважение к настройкам пользователя по уменьшению анимаций.
✨ Ключевые особенности
Без jQuery — работает на нативном JavaScript, быстрее и легче.
Семантичный HTML — используются <button>, role="dialog", aria-* атрибуты.
Полная доступность (a11y):
Захват и удержание фокуса внутри модального окна (focus trap).
Закрытие по клавише Escape.
Возврат фокуса на кнопку-триггер после закрытия.
Поддержка :focus-visible для клавиатурной навигации.
Плавная адаптивность:
- Типографика на clamp() — без медиазапросов.
- Высота экрана через 100dvh — корректно работает на мобильных.
- Спрайт-анимация пересчитывается через requestAnimationFrame.
- Уважение к предпочтениям — поддержка prefers-reduced-motion и prefers-color-scheme.
- CSS-переменные — единое управление цветами и шрифтами.
- Оптимизация производительности — will-change, passive listeners, отказ от вендорных префиксов.
- Спрайт-анимация из 25 кадров — плавный эффект растекания чернил (PNG-спрайт).
- Кроссбраузерность — работает во всех современных браузерах (Chrome, Firefox, Safari, Edge).
🧩 Как это работает
Пользователь нажимает кнопку «Start Effect».
Активируется слой перехода .transition-layer с анимацией cd-sequence (спрайт-анимация из 25 кадров).
Через 600 мс открывается модальное окно .modal, фокус перемещается внутрь.
При закрытии:
Запускается обратная анимация cd-sequence-reverse.
Модальное окно скрывается.
После завершения анимации слой перехода сбрасывается, фокус возвращается на кнопку.
🛠️ Технологии
- HTML5 — семантичная разметка, ARIA-атрибуты.
- CSS3 — переменные, clamp(), dvh, keyframes, prefers-reduced-motion.
- JavaScript (ES6+) — модульный IIFE, addEventListener, requestAnimationFrame, focus trap.
- Спрайт-анимация PNG — 25 кадров, соотношение 1.78.
Код
<style>
/* --------------------------------
Reset / Base
-------------------------------- */
*,
*::before,
*::after {
box-sizing: border-box;
}
:root {
--color-text: #242322;
--color-bg: #ffffff;
--color-accent: #a5c4bc;
--color-main-bg: #e4dcca;
--color-modal-bg: #402f44;
--font-body: "PT Sans", system-ui, sans-serif;
--font-heading: "IM Fell Double Pica", Georgia, serif;
}
html {
font-size: 62.5%;
}
body {
margin: 0;
font-size: 1.6rem;
font-family: var(--font-body);
color: var(--color-text);
background-color: var(--color-bg);
}
a {
color: var(--color-accent);
text-decoration: none;
}
/* --------------------------------
Typography
-------------------------------- */
h1 {
font-size: clamp(2.6rem, 4vw + 1rem, 5rem);
line-height: 1.1;
margin: 0 0 1em;
font-family: var(--font-heading);
font-style: italic;
}
/* --------------------------------
Buttons
-------------------------------- */
.btn {
display: inline-block;
padding: 1.6em 2.4em;
font-size: 1.4rem;
font-family: inherit;
letter-spacing: 0.15em;
color: #ffffff;
font-weight: 700;
text-transform: uppercase;
background-color: var(--color-accent);
border: none;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.3s, transform 0.2s;
}
@media (hover: hover) {
.btn:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
}
}
.btn:focus-visible {
outline: 3px solid #402f44;
outline-offset: 4px;
}
/* --------------------------------
Main content
-------------------------------- */
.main-content {
position: relative;
z-index: 1;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-main-bg);
padding: 2rem;
text-align: center;
}
/* --------------------------------
Modal
-------------------------------- */
.modal {
position: fixed;
inset: 0;
z-index: 3;
height: 100%;
width: 100%;
overflow: hidden;
background: var(--color-modal-bg) url("../effect/img/modal-bg.jpg")
no-repeat center center;
background-size: cover;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s 0s, visibility 0s 0.3s;
}
.modal.is-visible {
opacity: 1;
visibility: visible;
transition: opacity 0.7s, visibility 0s;
}
.modal__content {
height: 100%;
overflow-y: auto;
padding: 3em 5%;
color: var(--color-main-bg);
-webkit-font-smoothing: antialiased;
-webkit-overflow-scrolling: touch;
text-align: center;
}
.modal__content h1::after {
content: "";
display: block;
width: 130px;
height: 18px;
background: url("../effect/img/ink-separation.svg") no-repeat center center;
margin: 0.2em auto 0;
}
.modal__content p {
line-height: 1.6;
margin: 2em auto;
max-width: 800px;
}
.modal__close {
position: absolute;
z-index: 1;
top: 20px;
right: 5%;
height: 45px;
width: 45px;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.3)
url("../effect/img/cd-icon-close.svg") no-repeat center center;
cursor: pointer;
visibility: hidden;
opacity: 0;
transform: scale(0);
transition: transform 0.3s 0s, visibility 0s 0.3s, opacity 0.3s 0s;
}
@media (hover: hover) {
.modal__close:hover {
background-color: rgba(0, 0, 0, 0.5);
}
}
.modal__close:focus-visible {
outline: 3px solid #fff;
outline-offset: 3px;
}
.modal.is-visible .modal__close {
visibility: visible;
opacity: 1;
transform: scale(1);
transition: transform 0.3s 0s, visibility 0s 0s, opacity 0.3s 0s;
}
@media (min-width: 1100px) {
.modal__content {
padding: 6em 5%;
}
.modal__close {
height: 60px;
width: 60px;
}
.modal__content p {
font-size: 2rem;
}
}
/* --------------------------------
Transition layer
-------------------------------- */
.transition-layer {
position: fixed;
inset: 0;
z-index: 2;
height: 100%;
width: 100%;
opacity: 0;
visibility: hidden;
overflow: hidden;
pointer-events: none;
}
.transition-layer.is-visible {
opacity: 1;
visibility: visible;
}
.transition-layer__bg {
position: absolute;
left: 50%;
top: 50%;
height: 100%;
width: 2500%;
background: url("../effect/img/ink.png") no-repeat 0 0;
background-size: 100% 100%;
transform: translate(-2%, -50%);
will-change: transform;
}
.transition-layer.is-opening .transition-layer__bg {
animation: cd-sequence 0.8s steps(24) forwards;
}
.transition-layer.is-closing .transition-layer__bg {
animation: cd-sequence-reverse 0.8s steps(24) forwards;
}
@keyframes cd-sequence {
0% {
transform: translate(-2%, -50%);
}
100% {
transform: translate(-98%, -50%);
}
}
@keyframes cd-sequence-reverse {
0% {
transform: translate(-98%, -50%);
}
100% {
transform: translate(-2%, -50%);
}
}
/* Fallback для пользователей, предпочитающих меньше анимаций */
@media (prefers-reduced-motion: reduce) {
.transition-layer {
display: none;
}
.modal,
.modal__close {
transition-duration: 0.01ms !important;
}
}
/* --------------------------------
Utilities
-------------------------------- */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
<link
href="https://fonts.googleapis.com/css2?family=IM+Fell+Double+Pica:ital@0;1&family=PT+Sans:wght@400;700&display=swap"
rel="stylesheet"
/>
<main class="main-content">
<div class="center">
<h1>Ink Transition Effect</h1>
<button type="button" class="btn modal-trigger" aria-haspopup="dialog">
Start Effect
</button>
</div>
</main>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-hidden="true"
>
<div class="modal__content">
<h1 id="modal-title">My Modal Content</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
Ad modi repellendus, optio eveniet eligendi molestiae?
Fugiat, temporibus!
</p>
</div>
<button type="button" class="modal__close" aria-label="Закрыть окно">
<span class="visually-hidden">Закрыть</span>
</button>
</div>
<div class="transition-layer" aria-hidden="true">
<div class="transition-layer__bg"></div>
</div>
<script>
(() => {
'use strict';
const modalTrigger = document.querySelector('.modal-trigger');
const modal = document.querySelector('.modal');
const modalClose = document.querySelector('.modal__close');
const transitionLayer = document.querySelector('.transition-layer');
const transitionBg = transitionLayer.querySelector('.transition-layer__bg');
const FRAME_PROPORTION = 1.78; // соотношение сторон одного кадра PNG
const FRAMES = 25; // число кадров спрайта
let lastFocusedElement = null;
let isAnimating = false;
/* ---------- Размеры спрайта ---------- */
function setLayerDimensions() {
const w = window.innerWidth;
const h = window.innerHeight;
let layerHeight;
let layerWidth;
if (w / h > FRAME_PROPORTION) {
layerWidth = w;
layerHeight = layerWidth / FRAME_PROPORTION;
} else {
layerHeight = h * 1.2;
layerWidth = layerHeight * FRAME_PROPORTION;
}
transitionBg.style.width = `${layerWidth * FRAMES}px`;
transitionBg.style.height = `${layerHeight}px`;
}
/* ---------- Оптимизированный resize ---------- */
let resizeScheduled = false;
function scheduleResize() {
if (resizeScheduled) return;
resizeScheduled = true;
requestAnimationFrame(() => {
setLayerDimensions();
resizeScheduled = false;
});
}
window.addEventListener('resize', scheduleResize, { passive: true });
/* ---------- Открытие модального окна ---------- */
function openModal() {
if (isAnimating) return;
isAnimating = true;
lastFocusedElement = document.activeElement;
transitionLayer.classList.add('is-visible', 'is-opening');
modal.setAttribute('aria-hidden', 'false');
const delay = 600;
window.setTimeout(() => {
modal.classList.add('is-visible');
document.body.style.overflow = 'hidden';
modalClose.focus();
isAnimating = false;
}, delay);
}
/* ---------- Закрытие модального окна ---------- */
function closeModal() {
if (isAnimating) return;
isAnimating = true;
transitionLayer.classList.add('is-closing');
modal.classList.remove('is-visible');
modal.setAttribute('aria-hidden', 'true');
const onEnd = (event) => {
if (event.target !== transitionBg) return;
transitionBg.removeEventListener('animationend', onEnd);
transitionLayer.classList.remove('is-closing', 'is-opening', 'is-visible');
document.body.style.overflow = '';
isAnimating = false;
if (lastFocusedElement) lastFocusedElement.focus();
};
transitionBg.addEventListener('animationend', onEnd);
}
/* ---------- Ловушка фокуса внутри модального окна ---------- */
function trapFocus(event) {
if (event.key !== 'Tab') return;
const focusable = modal.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
/* ---------- События ---------- */
modalTrigger.addEventListener('click', openModal);
modalClose.addEventListener('click', closeModal);
modal.addEventListener('keydown', trapFocus);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && modal.classList.contains('is-visible')) {
closeModal();
}
});
/* ---------- Инициализация ---------- */
setLayerDimensions();
/* ---------- Проверка поддержки CSS-анимаций ---------- */
const supportsAnimations =
'animation' in document.documentElement.style ||
'webkitAnimation' in document.documentElement.style;
if (!supportsAnimations) {
transitionLayer.style.display = 'none';
}
})();
</script>
/* --------------------------------
Reset / Base
-------------------------------- */
*,
*::before,
*::after {
box-sizing: border-box;
}
:root {
--color-text: #242322;
--color-bg: #ffffff;
--color-accent: #a5c4bc;
--color-main-bg: #e4dcca;
--color-modal-bg: #402f44;
--font-body: "PT Sans", system-ui, sans-serif;
--font-heading: "IM Fell Double Pica", Georgia, serif;
}
html {
font-size: 62.5%;
}
body {
margin: 0;
font-size: 1.6rem;
font-family: var(--font-body);
color: var(--color-text);
background-color: var(--color-bg);
}
a {
color: var(--color-accent);
text-decoration: none;
}
/* --------------------------------
Typography
-------------------------------- */
h1 {
font-size: clamp(2.6rem, 4vw + 1rem, 5rem);
line-height: 1.1;
margin: 0 0 1em;
font-family: var(--font-heading);
font-style: italic;
}
/* --------------------------------
Buttons
-------------------------------- */
.btn {
display: inline-block;
padding: 1.6em 2.4em;
font-size: 1.4rem;
font-family: inherit;
letter-spacing: 0.15em;
color: #ffffff;
font-weight: 700;
text-transform: uppercase;
background-color: var(--color-accent);
border: none;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.3s, transform 0.2s;
}
@media (hover: hover) {
.btn:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
}
}
.btn:focus-visible {
outline: 3px solid #402f44;
outline-offset: 4px;
}
/* --------------------------------
Main content
-------------------------------- */
.main-content {
position: relative;
z-index: 1;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-main-bg);
padding: 2rem;
text-align: center;
}
/* --------------------------------
Modal
-------------------------------- */
.modal {
position: fixed;
inset: 0;
z-index: 3;
height: 100%;
width: 100%;
overflow: hidden;
background: var(--color-modal-bg) url("../effect/img/modal-bg.jpg")
no-repeat center center;
background-size: cover;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s 0s, visibility 0s 0.3s;
}
.modal.is-visible {
opacity: 1;
visibility: visible;
transition: opacity 0.7s, visibility 0s;
}
.modal__content {
height: 100%;
overflow-y: auto;
padding: 3em 5%;
color: var(--color-main-bg);
-webkit-font-smoothing: antialiased;
-webkit-overflow-scrolling: touch;
text-align: center;
}
.modal__content h1::after {
content: "";
display: block;
width: 130px;
height: 18px;
background: url("../effect/img/ink-separation.svg") no-repeat center center;
margin: 0.2em auto 0;
}
.modal__content p {
line-height: 1.6;
margin: 2em auto;
max-width: 800px;
}
.modal__close {
position: absolute;
z-index: 1;
top: 20px;
right: 5%;
height: 45px;
width: 45px;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.3)
url("../effect/img/cd-icon-close.svg") no-repeat center center;
cursor: pointer;
visibility: hidden;
opacity: 0;
transform: scale(0);
transition: transform 0.3s 0s, visibility 0s 0.3s, opacity 0.3s 0s;
}
@media (hover: hover) {
.modal__close:hover {
background-color: rgba(0, 0, 0, 0.5);
}
}
.modal__close:focus-visible {
outline: 3px solid #fff;
outline-offset: 3px;
}
.modal.is-visible .modal__close {
visibility: visible;
opacity: 1;
transform: scale(1);
transition: transform 0.3s 0s, visibility 0s 0s, opacity 0.3s 0s;
}
@media (min-width: 1100px) {
.modal__content {
padding: 6em 5%;
}
.modal__close {
height: 60px;
width: 60px;
}
.modal__content p {
font-size: 2rem;
}
}
/* --------------------------------
Transition layer
-------------------------------- */
.transition-layer {
position: fixed;
inset: 0;
z-index: 2;
height: 100%;
width: 100%;
opacity: 0;
visibility: hidden;
overflow: hidden;
pointer-events: none;
}
.transition-layer.is-visible {
opacity: 1;
visibility: visible;
}
.transition-layer__bg {
position: absolute;
left: 50%;
top: 50%;
height: 100%;
width: 2500%;
background: url("../effect/img/ink.png") no-repeat 0 0;
background-size: 100% 100%;
transform: translate(-2%, -50%);
will-change: transform;
}
.transition-layer.is-opening .transition-layer__bg {
animation: cd-sequence 0.8s steps(24) forwards;
}
.transition-layer.is-closing .transition-layer__bg {
animation: cd-sequence-reverse 0.8s steps(24) forwards;
}
@keyframes cd-sequence {
0% {
transform: translate(-2%, -50%);
}
100% {
transform: translate(-98%, -50%);
}
}
@keyframes cd-sequence-reverse {
0% {
transform: translate(-98%, -50%);
}
100% {
transform: translate(-2%, -50%);
}
}
/* Fallback для пользователей, предпочитающих меньше анимаций */
@media (prefers-reduced-motion: reduce) {
.transition-layer {
display: none;
}
.modal,
.modal__close {
transition-duration: 0.01ms !important;
}
}
/* --------------------------------
Utilities
-------------------------------- */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
<link
href="https://fonts.googleapis.com/css2?family=IM+Fell+Double+Pica:ital@0;1&family=PT+Sans:wght@400;700&display=swap"
rel="stylesheet"
/>
<main class="main-content">
<div class="center">
<h1>Ink Transition Effect</h1>
<button type="button" class="btn modal-trigger" aria-haspopup="dialog">
Start Effect
</button>
</div>
</main>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-hidden="true"
>
<div class="modal__content">
<h1 id="modal-title">My Modal Content</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
Ad modi repellendus, optio eveniet eligendi molestiae?
Fugiat, temporibus!
</p>
</div>
<button type="button" class="modal__close" aria-label="Закрыть окно">
<span class="visually-hidden">Закрыть</span>
</button>
</div>
<div class="transition-layer" aria-hidden="true">
<div class="transition-layer__bg"></div>
</div>
<script>
(() => {
'use strict';
const modalTrigger = document.querySelector('.modal-trigger');
const modal = document.querySelector('.modal');
const modalClose = document.querySelector('.modal__close');
const transitionLayer = document.querySelector('.transition-layer');
const transitionBg = transitionLayer.querySelector('.transition-layer__bg');
const FRAME_PROPORTION = 1.78; // соотношение сторон одного кадра PNG
const FRAMES = 25; // число кадров спрайта
let lastFocusedElement = null;
let isAnimating = false;
/* ---------- Размеры спрайта ---------- */
function setLayerDimensions() {
const w = window.innerWidth;
const h = window.innerHeight;
let layerHeight;
let layerWidth;
if (w / h > FRAME_PROPORTION) {
layerWidth = w;
layerHeight = layerWidth / FRAME_PROPORTION;
} else {
layerHeight = h * 1.2;
layerWidth = layerHeight * FRAME_PROPORTION;
}
transitionBg.style.width = `${layerWidth * FRAMES}px`;
transitionBg.style.height = `${layerHeight}px`;
}
/* ---------- Оптимизированный resize ---------- */
let resizeScheduled = false;
function scheduleResize() {
if (resizeScheduled) return;
resizeScheduled = true;
requestAnimationFrame(() => {
setLayerDimensions();
resizeScheduled = false;
});
}
window.addEventListener('resize', scheduleResize, { passive: true });
/* ---------- Открытие модального окна ---------- */
function openModal() {
if (isAnimating) return;
isAnimating = true;
lastFocusedElement = document.activeElement;
transitionLayer.classList.add('is-visible', 'is-opening');
modal.setAttribute('aria-hidden', 'false');
const delay = 600;
window.setTimeout(() => {
modal.classList.add('is-visible');
document.body.style.overflow = 'hidden';
modalClose.focus();
isAnimating = false;
}, delay);
}
/* ---------- Закрытие модального окна ---------- */
function closeModal() {
if (isAnimating) return;
isAnimating = true;
transitionLayer.classList.add('is-closing');
modal.classList.remove('is-visible');
modal.setAttribute('aria-hidden', 'true');
const onEnd = (event) => {
if (event.target !== transitionBg) return;
transitionBg.removeEventListener('animationend', onEnd);
transitionLayer.classList.remove('is-closing', 'is-opening', 'is-visible');
document.body.style.overflow = '';
isAnimating = false;
if (lastFocusedElement) lastFocusedElement.focus();
};
transitionBg.addEventListener('animationend', onEnd);
}
/* ---------- Ловушка фокуса внутри модального окна ---------- */
function trapFocus(event) {
if (event.key !== 'Tab') return;
const focusable = modal.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
/* ---------- События ---------- */
modalTrigger.addEventListener('click', openModal);
modalClose.addEventListener('click', closeModal);
modal.addEventListener('keydown', trapFocus);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && modal.classList.contains('is-visible')) {
closeModal();
}
});
/* ---------- Инициализация ---------- */
setLayerDimensions();
/* ---------- Проверка поддержки CSS-анимаций ---------- */
const supportsAnimations =
'animation' in document.documentElement.style ||
'webkitAnimation' in document.documentElement.style;
if (!supportsAnimations) {
transitionLayer.style.display = 'none';
}
})();
</script>

0 комментариев
Присоединиться к обсуждению
Поделитесь мнением об этом материале — комментарий появится в списке слева сразу после отправки.