Частичная анимация pagepill на многоэкранной странице
Rus
Eng
Частичная анимация pagepill на многоэкранной странице
Решаю сложные технические задачи для Bitrix и 1С: от аудита чужого кода до настройки сложных интеграций с гарантией результата
Рекомендую

Хостинг для сайта

Если выбираете хостинг под проект — советую Timeweb. Современная инфраструктура, широкий выбор тарифов от стартовых до VPS под нагрузку, отзывчивая техподдержка, которая отвечает по существу, а не отписками. Я сам держу на нём часть своих проектов и рекомендую клиентам.

Перейти Партнёрская ссылка. Для вас цена не меняется.

Задача: залипающие первые два экрана на длинном лендинге

На лендинге 13 секций. Первые две должны листаться как слайды — по одной на весь вьюпорт, с плавным переходом и точками навигации. С третьего экрана должен работать обычный нативный скролл. Классический pagepiling.js здесь не подходит: он «залипает» на всех секциях подряд, и приходится либо идти на хак с normalScrollElements, либо переписывать логику своими руками. Второй путь чище: полторы сотни строк собственного скрипта решают задачу предсказуемо и без лишних зависимостей.

Ключевая идея: две независимые области

Страница делится на две части. Первая — фиксированный оверлей position: fixed, внутри которого лежат только первые два экрана. Они перелистываются через transform: translateY. Вторая — обычный контент в потоке документа, начиная с третьего экрана. Он скроллится нативно, и оверлей в его жизни не участвует.

Переключение между режимами — через класс на <body>. Пока класс piling-locked висит, оверлей активен, а нативный скролл заблокирован через overflow: hidden. Как только класс снимается, оверлей уезжает за верхнюю границу экрана, и страница начинает скроллиться как обычно.

Разметка для шаблона Bitrix

В Bitrix такая страница обычно собирается в header.php и footer.php шаблона сайта. Первые два экрана — это отдельные секции, которые по смыслу не относятся ни к одному компоненту и живут прямо в header.php. Всё, что после второго экрана, идёт из компонентов news.list, catalog.section и подобных.

Разметка оверлея и точек навигации выводится в header.php сразу после открытия <body>. Основной контент — стандартный вывод шаблона, без изменений.

<body class="piling-locked">

    <header class="header">ШАПКА САЙТА</header>

    <div id="pagepiling" class="piling">
        <div class="piling__track" id="pilingTrack">
            <section class="js--slide" id="main">Экран 1</section>
            <section class="js--slide" id="works">Экран 2</section>
        </div>
    </div>

    <nav class="piling-nav" id="pilingNav" aria-hidden="true"></nav>

Дальше начинается обычный контент. В footer.php перед закрытием </body> подключается сам скрипт — об этом ниже.

CSS: оверлей, трек, слайды

Оверлей фиксируется на весь вьюпорт и получает собственный z-index. Трек сдвигается через transform с той же кривой и той же длительностью, что и уезд самого оверлея. Слайды внутри трека — ровно высота вьюпорта.

body.piling-locked {
    overflow: hidden;
    overscroll-behavior-y: contain;
}

#pagepiling.piling {
    position: fixed;
    inset: 0;
    width: 100%;
    height: 100vh;
    height: 100dvh;
    overflow: hidden;
    z-index: 10;
    transition: transform 1s cubic-bezier(.77, 0, .18, 1);
    will-change: transform;
}

#pagepiling.piling.piling--up {
    transform: translateY(-100%);
}

.piling__track {
    transition: transform 1s cubic-bezier(.77, 0, .18, 1);
    will-change: transform;
}

#pagepiling.piling > .piling__track > .js--slide {
    box-sizing: border-box;
    height: 100vh !important;
    height: 100dvh !important;
    overflow: hidden;
}

Несколько решений, которые стоит проговорить отдельно. height: 100dvh вместо 100vh — потому что на iOS Safari динамическая адресная строка «съедает» часть vh, и слайд обрезается снизу. box-sizing: border-box гарантирует, что padding внутри слайда не вытолкнет его за границы вьюпорта. z-index: 10 у оверлея меньше, чем у шапки (z-index: 100), чтобы шапка не перекрывалась.

Сдвиг трека: почему в процентах, а не в vh

Сдвиг считается в процентах от высоты трека, а не в vh. Это принципиально: -100vh не совпадает с 100dvh, и на мобильных слайды «садятся» со сдвигом на несколько десятков пикселей. Процент от собственной высоты трека работает безошибочно в любом браузере.

function updateTrack(animated) {
    var pct = (currentIndex * 100) / TOTAL;
    if (!animated) track.style.transition = 'none';
    track.style.transform = 'translate3d(0, -' + pct + '%, 0)';
    if (!animated) {
        requestAnimationFrame(function () {
            requestAnimationFrame(function () { track.style.transition = ''; });
        });
    }
}

Двойной requestAnimationFrame при неанимированном сдвиге нужен, чтобы браузер успел применить изменения без перехода и только потом вернул transition назад. Одиночного кадра не хватает — стили не успевают «прилипнуть».

Перехват скролла: wheel и touchmove

Пока оверлей активен, события wheel и touchmove перехватываются с preventDefault(). Без флага passive: false браузер игнорирует preventDefault() — это частая ошибка, на которой теряются часы отладки.

window.addEventListener('wheel', function (e) {
    if (!pilingEnabled) return;

    if (isLocked) {
        e.preventDefault();
        if (Date.now() < lockCooldownUntil) return;
        if (isAnimating) return;

        if (e.deltaY > 0) {
            if (currentIndex < TOTAL - 1) goTo(currentIndex + 1);
            else unlock();
        } else {
            if (currentIndex > 0) goTo(currentIndex - 1);
        }
        return;
    }

    if (tryReturnToPiling(e.deltaY)) e.preventDefault();
}, { passive: false });

Для touchmove дополнительно используется порог свайпа — Math.abs(dy) < 40. Без него лёгкое касание листает слайд, и интерфейс начинает «дёргаться» на каждый жест. Флаг isAnimating блокирует параллельные вызовы goTo(), пока предыдущий переход не завершился.

Главная ловушка: инерция скролла при возврате

Пользователь удерживает скролл вверх, вылетает с третьего экрана на первый, пропуская второй. Или наоборот — возврат в оверлей срабатывает случайно при лёгком «доводчике» колеса. Причина — инерция: после выхода на scrollY === 0 колесо или тачпад продолжает «докручивать» вверх ещё одну-две секунды. Каждое событие становится поводом вызвать lock() и снова дёрнуть трек.

Решение из двух частей. Первое — накопитель усилия. Возврат срабатывает не от одного события, а от суммы движений вверх.

var RETURN_THRESHOLD = 250;
var RETURN_RESET_MS  = 250;

function tryReturnToPiling(deltaY) {
    if (!pilingEnabled) return false;
    if (window.scrollY > 2) { scrollUpAccum = 0; return false; }
    if (deltaY >= 0)        { scrollUpAccum = 0; return false; }

    scrollUpAccum += Math.abs(deltaY);
    clearTimeout(scrollUpResetTimer);
    scrollUpResetTimer = setTimeout(function () { scrollUpAccum = 0; }, RETURN_RESET_MS);

    if (scrollUpAccum >= RETURN_THRESHOLD) lock();
    return true;
}

Короткий «доводчик» набирает 30–50 — не срабатывает. Осознанный жест на 250 и больше — срабатывает. Второе — кулдаун после возврата: первые 700 миллисекунд после lock() любые события скролла игнорируются, инерция успевает улечься. Без кулдауна пользователь видит возврат на второй экран и мгновенный проскок на первый.

Плавный уход и возврат оверлея

Резко скрывать оверлей через display: none нельзя — будет «щелчок». Он должен уезжать вверх, открывая нативный скролл. Возврат — зеркальная операция: оверлей ставится за верхнюю границу мгновенно, без transition, трек уже переброшен на последний слайд, и только потом через двойной requestAnimationFrame оверлей плавно опускается вниз.

function lock() {
    if (!pilingEnabled || isAnimating) return;
    isAnimating = true;

    isLocked = true;
    document.body.classList.add('piling-locked');
    window.scrollTo(0, 0);

    currentIndex = TOTAL - 1;
    updateTrack(false);
    setDot(currentIndex);
    syncBodyState();

    piling.style.transition = 'none';
    piling.classList.add('piling--up');
    nav.classList.add('is-hidden');
    void piling.offsetHeight;
    piling.style.transition = '';

    requestAnimationFrame(function () {
        requestAnimationFrame(function () {
            piling.classList.remove('piling--up');
            nav.classList.remove('is-hidden');
        });
    });

    setTimeout(function () {
        isAnimating = false;
        lockCooldownUntil = Date.now() + LOCK_COOLDOWN_MS;
        resetReturnState();
    }, OVERLAY_ANIM_MS);
}

Синхронизация таймингов критична: ANIM_MS === OVERLAY_ANIM_MS === transition в CSS. Все переходы — одна секунда с одной кривой cubic-bezier(.77, 0, .18, 1). Тогда перелистывание между первым и вторым экраном и уход в скролл воспринимаются как одно и то же движение.

Шапка: видна на первом экране, скрыта на втором

На первом экране шапка должна лежать поверх баннера. На втором — отсутствовать, чтобы не закрывать контент. Управляется классом на <body>. Высота шапки при этом пишется в CSS-переменную --header-height, чтобы остальные блоки могли её учитывать.

function syncHeaderHeight() {
    var header = document.querySelector('header') || document.querySelector('.header');
    if (!header) return;
    var h = header.offsetHeight || 0;
    if (document.body.classList.contains('panel')) h += 40;
    document.documentElement.style.setProperty('--header-height', h + 'px');
}
syncHeaderHeight();
window.addEventListener('load', syncHeaderHeight);
window.addEventListener('resize', syncHeaderHeight);
body.piling-locked header,
body.piling-locked .header {
    z-index: 200 !important;
    transition: transform .45s cubic-bezier(.5, 0, .2, 1);
}

body.piling-hide-header header,
body.piling-hide-header .header {
    transform: translateY(-100%);
}

Класс piling-hide-header переключается функцией syncBodyState() после каждого goTo(), lock(), unlock() и enablePiling().

Отключение на мобильных

На узких вьюпортах piling вреден: свайпы конфликтуют с жестами операционной системы, баннер сжимается, адресная строка ведёт себя непредсказуемо. Решение — мгновенно выключать piling при ширине меньше 992 пикселей. Класс piling--off переводит оверлей из position: fixed в обычный поток, и вся логика отваливается одним махом.

#pagepiling.piling.piling--off {
    position: static;
    height: auto;
    transform: none !important;
    transition: none;
    overflow: visible;
    z-index: auto;
}

#pagepiling.piling.piling--off .piling__track {
    transform: none !important;
    transition: none;
}

#pagepiling.piling.piling--off > .piling__track > .js--slide {
    height: auto !important;
    min-height: 100vh;
    min-height: 100dvh;
    overflow: visible;
}

#pagepiling.piling.piling--off ~ .piling-nav {
    display: none;
}

При переключении ширины туда-обратно не остаётся «подвисших» состояний: класс piling-locked снимается, шапка возвращается, скролл корректный. Открытие сразу на мобильном инициализируется уже выключенным. Поворот телефона в ландшафт при ширине ≥ 992 включает piling, обратно — выключает.

Подключение в шаблоне Bitrix

Скрипт кладётся в /local/js/piling/piling.js рядом с шаблоном сайта. Стили — в /local/js/piling/piling.css. Прямое подключение через <script src="..."> в header.php использовать не нужно — Bitrix подключает ассеты штатным образом.

Вариант первый — через Asset::addJs() и Asset::addCss() в footer.php шаблона сайта, чтобы скрипт подгружался после разметки:

&lt;?php
use Bitrix\Main\Page\Asset;

Asset::addCss('/local/js/piling/piling.css');
Asset::addJs('/local/js/piling/piling.js');
?&gt;

Вариант второй — если шаблон собирается через компоненты, а piling нужен только на главной, подключайте скрипт из component_epilog.php соответствующего компонента или из script.js шаблона сайта. В script.js достаточно обернуть логику в проверку наличия #pagepiling — на остальных страницах скрипт просто не сработает.

Разметку оверлея и точек навигации удобно хранить в header.php шаблона, а не в компоненте: она относится ко всей странице целиком. Если лендинг одностраничный — можно вынести в отдельный .php-файл и подключать через include.

Полный рабочий скрипт

Ниже — итоговый piling.js. Его можно открыть в браузере на тестовой странице и потрогать руками до интеграции в проект.

(function () {
    var track = document.getElementById('pilingTrack');
    var piling = document.getElementById('pagepiling');
    var nav = document.getElementById('pilingNav');
    if (!track || !piling || !nav) return;

    var slides = track.querySelectorAll(':scope > .js--slide');
    var TOTAL = slides.length;
    if (TOTAL < 2) return;

    var BREAKPOINT = 992;

    var ANIM_MS           = 1000;
    var OVERLAY_ANIM_MS   = 1000;
    var RETURN_THRESHOLD  = 250;
    var RETURN_RESET_MS   = 250;
    var LOCK_COOLDOWN_MS  = 700;
    var TOUCH_SWIPE_MIN   = 40;

    function syncHeaderHeight() {
        var header = document.querySelector('header') || document.querySelector('.header');
        if (!header) return;
        var h = header.offsetHeight || 0;
        if (document.body.classList.contains('panel')) h += 40;
        document.documentElement.style.setProperty('--header-height', h + 'px');
    }
    syncHeaderHeight();
    window.addEventListener('load', syncHeaderHeight);
    window.addEventListener('resize', syncHeaderHeight);

    var dots = [];
    for (var i = 0; i < TOTAL; i++) {
        var b = document.createElement('button');
        b.type = 'button';
        b.className = 'piling-nav__dot' + (i === 0 ? ' is-active' : '');
        b.dataset.index = i;
        b.setAttribute('aria-label', 'Перейти на экран ' + (i + 1));
        nav.appendChild(b);
        dots.push(b);
    }

    var currentIndex = 0;
    var isLocked = true;
    var isAnimating = false;
    var touchStartY = 0;
    var scrollUpAccum = 0;
    var scrollUpResetTimer = null;
    var lockCooldownUntil = 0;

    var pilingEnabled = window.innerWidth >= BREAKPOINT;

    function setDot(i) {
        for (var k = 0; k < dots.length; k++) {
            dots[k].classList.toggle('is-active', k === i);
        }
    }

    function syncBodyState() {
        document.body.classList.toggle(
            'piling-hide-header',
            pilingEnabled && isLocked && currentIndex > 0
        );
    }

    function updateTrack(animated) {
        var pct = (currentIndex * 100) / TOTAL;
        if (!animated) track.style.transition = 'none';
        track.style.transform = 'translate3d(0, -' + pct + '%, 0)';
        if (!animated) {
            requestAnimationFrame(function () {
                requestAnimationFrame(function () { track.style.transition = ''; });
            });
        }
    }

    function resetReturnState() {
        scrollUpAccum = 0;
        clearTimeout(scrollUpResetTimer);
    }

    function enablePiling() {
        if (pilingEnabled) return;
        pilingEnabled = true;

        currentIndex = 0;
        isLocked = true;
        isAnimating = false;

        updateTrack(false);
        setDot(0);
        window.scrollTo(0, 0);

        piling.classList.remove('piling--off', 'piling--up');
        document.body.classList.add('piling-locked');
        nav.classList.remove('is-hidden');
        syncBodyState();
    }

    function disablePiling() {
        if (!pilingEnabled) return;
        pilingEnabled = false;

        isLocked = false;
        isAnimating = false;

        piling.classList.add('piling--off');
        piling.classList.remove('piling--up');
        document.body.classList.remove('piling-locked', 'piling-hide-header');
        nav.classList.add('is-hidden');
        resetReturnState();
    }

    function checkBreakpoint() {
        var shouldEnable = window.innerWidth >= BREAKPOINT;
        if (shouldEnable && !pilingEnabled)  enablePiling();
        else if (!shouldEnable && pilingEnabled) disablePiling();
    }

    function goTo(index) {
        if (!pilingEnabled) return;
        if (isAnimating || index === currentIndex || index < 0 || index >= TOTAL) return;
        isAnimating = true;
        currentIndex = index;
        updateTrack(true);
        setDot(index);
        syncBodyState();
        setTimeout(function () { isAnimating = false; }, ANIM_MS);
    }

    function unlock() {
        if (!pilingEnabled || isAnimating) return;
        isAnimating = true;

        document.body.classList.remove('piling-locked');
        piling.classList.add('piling--up');
        nav.classList.add('is-hidden');

        setTimeout(function () {
            isLocked = false;
            isAnimating = false;
            document.body.classList.remove('piling-hide-header');
            resetReturnState();
        }, OVERLAY_ANIM_MS);
    }

    function lock() {
        if (!pilingEnabled || isAnimating) return;
        isAnimating = true;

        isLocked = true;
        document.body.classList.add('piling-locked');
        window.scrollTo(0, 0);

        currentIndex = TOTAL - 1;
        updateTrack(false);
        setDot(currentIndex);
        syncBodyState();

        piling.style.transition = 'none';
        piling.classList.add('piling--up');
        nav.classList.add('is-hidden');
        void piling.offsetHeight;
        piling.style.transition = '';

        requestAnimationFrame(function () {
            requestAnimationFrame(function () {
                piling.classList.remove('piling--up');
                nav.classList.remove('is-hidden');
            });
        });

        setTimeout(function () {
            isAnimating = false;
            lockCooldownUntil = Date.now() + LOCK_COOLDOWN_MS;
            resetReturnState();
        }, OVERLAY_ANIM_MS);
    }

    function tryReturnToPiling(deltaY) {
        if (!pilingEnabled) return false;
        if (window.scrollY > 2) { scrollUpAccum = 0; return false; }
        if (deltaY >= 0)        { scrollUpAccum = 0; return false; }

        scrollUpAccum += Math.abs(deltaY);
        clearTimeout(scrollUpResetTimer);
        scrollUpResetTimer = setTimeout(function () { scrollUpAccum = 0; }, RETURN_RESET_MS);

        if (scrollUpAccum >= RETURN_THRESHOLD) lock();
        return true;
    }

    if (pilingEnabled) {
        document.body.classList.add('piling-locked');
        piling.classList.remove('piling--off');
        nav.classList.remove('is-hidden');
        syncBodyState();
    } else {
        piling.classList.add('piling--off');
        document.body.classList.remove('piling-locked');
        nav.classList.add('is-hidden');
    }

    var resizeTimer = null;
    window.addEventListener('resize', function () {
        clearTimeout(resizeTimer);
        resizeTimer = setTimeout(checkBreakpoint, 100);
    });

    window.addEventListener('wheel', function (e) {
        if (!pilingEnabled) return;

        if (isLocked) {
            e.preventDefault();
            if (Date.now() < lockCooldownUntil) return;
            if (isAnimating) return;

            if (e.deltaY > 0) {
                if (currentIndex < TOTAL - 1) goTo(currentIndex + 1);
                else unlock();
            } else {
                if (currentIndex > 0) goTo(currentIndex - 1);
            }
            return;
        }

        if (tryReturnToPiling(e.deltaY)) e.preventDefault();
    }, { passive: false });

    window.addEventListener('touchstart', function (e) {
        touchStartY = e.touches[0].clientY;
    }, { passive: true });

    window.addEventListener('touchmove', function (e) {
        if (!pilingEnabled) return;

        var dy = touchStartY - e.touches[0].clientY;

        if (isLocked) {
            e.preventDefault();
            if (Date.now() < lockCooldownUntil) { touchStartY = e.touches[0].clientY; return; }
            if (isAnimating || Math.abs(dy) < TOUCH_SWIPE_MIN) return;

            if (dy > 0) {
                if (currentIndex < TOTAL - 1) goTo(currentIndex + 1);
                else unlock();
            } else {
                if (currentIndex > 0) goTo(currentIndex - 1);
            }
            touchStartY = e.touches[0].clientY;
            return;
        }

        if (window.scrollY <= 2 && dy < 0) {
            scrollUpAccum += Math.abs(dy);
            touchStartY = e.touches[0].clientY;
            clearTimeout(scrollUpResetTimer);
            scrollUpResetTimer = setTimeout(function () { scrollUpAccum = 0; }, RETURN_RESET_MS);
            if (scrollUpAccum >= RETURN_THRESHOLD) {
                e.preventDefault();
                lock();
            }
        } else {
            scrollUpAccum = 0;
        }
    }, { passive: false });

    dots.forEach(function (dot) {
        dot.addEventListener('click', function () {
            if (!pilingEnabled || !isLocked) return;
            goTo(parseInt(dot.dataset.index, 10));
        });
    });
})();

Ниже — итоговый piling.css.

body.piling-locked {
    overflow: hidden;
    overscroll-behavior-y: contain;
}

body.piling-locked header,
body.piling-locked .header {
    z-index: 200 !important;
    transition: transform .45s cubic-bezier(.5, 0, .2, 1);
}
body.piling-hide-header header,
body.piling-hide-header .header {
    transform: translateY(-100%);
}

#pagepiling.piling {
    position: fixed;
    inset: 0;
    width: 100%;
    height: 100vh;
    height: 100dvh;
    overflow: hidden;
    z-index: 10;
    transition: transform 1s cubic-bezier(.77, 0, .18, 1);
    will-change: transform;
}

#pagepiling.piling.piling--up {
    transform: translateY(-100%);
}

.piling__track {
    transition: transform 1s cubic-bezier(.77, 0, .18, 1);
    will-change: transform;
}

#pagepiling.piling > .piling__track > .js--slide {
    box-sizing: border-box;
    height: 100vh !important;
    height: 100dvh !important;
    overflow: hidden;
}

.piling-nav {
    position: fixed;
    top: 50%;
    right: 30px;
    transform: translateY(-50%);
    z-index: 11;
    display: flex;
    flex-direction: column;
    gap: 14px;
    transition: opacity .3s ease, visibility .3s ease;
}
.piling-nav.is-hidden {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
}

.piling-nav__dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    border: 2px solid #000;
    background: transparent;
    cursor: pointer;
    padding: 0;
    transition: background .3s, transform .3s;
}
.piling-nav__dot.is-active {
    background: #000;
    transform: scale(1.2);
}

.main-content {
    position: relative;
    z-index: 1;
}

#pagepiling.piling.piling--off {
    position: static;
    height: auto;
    transform: none !important;
    transition: none;
    overflow: visible;
    z-index: auto;
}
#pagepiling.piling.piling--off .piling__track {
    transform: none !important;
    transition: none;
}
#pagepiling.piling.piling--off > .piling__track > .js--slide {
    height: auto !important;
    min-height: 100vh;
    min-height: 100dvh;
    overflow: visible;
}
#pagepiling.piling.piling--off + .piling-nav,
#pagepiling.piling.piling--off ~ .piling-nav {
    display: none;
}

Ниже — итоговая разметка.

<!-- Pagepiling blocks -->
<div id="pagepiling" class="piling">
    <div class="piling__track" id="pilingTrack">
        <div class="js--slide" id="main">
			Экран 1
		</div>
        <div class="js--slide" id="works">
			Экран 2
		</div>
	</div>
</div>

<!-- Навигационные точки -->
<nav class="piling-nav" id="pilingNav" aria-hidden="true"></nav>

<!-- Стандартный контент -->
<div class="main-content" id="mainContent">
</div>

Чек-лист внедрения

  1. Разметить оверлей #pagepiling и точки #pilingNav в header.php шаблона сайта.
  2. Положить piling.css и piling.js в /local/js/piling/.
  3. Подключить ассеты через Asset::addCss() и Asset::addJs() в footer.php.
  4. Проверить, что --header-height устанавливается при загрузке и при ресайзе.
  5. Проверить работу на реальных устройствах: тачпад Mac, колесо Windows и свайп Android ведут себя по-разному, пороги придётся калибровать.
  6. Убедиться, что на мобильных piling отключается — ширина меньше 992 пикселей и класс piling--off на оверлее.

Что можно доработать дальше

Параллакс внутри слайда — лёгкий translateY фона при перелистывании даёт ощущение глубины. Появление текста — opacity и translateY для заголовка внутри goTo(). Навигация с клавиатуры — обработчик keydown для стрелок и PageUp/PageDown, это нужно для доступности. Разделение таймингов — если стандартная секунда кажется долгой при уходе в скролл, можно развести ANIM_MS и OVERLAY_ANIM_MS, но тогда придётся переписать transition у #pagepiling отдельно от .piling__track.

Александр Тихий
Автор статьи

Александр Тихий

Fullstack-разработчик · 1С-Битрикс

Разрабатываю и развиваю интернет-магазины и корпоративные порталы на 1С-Битрикс. Интеграции с 1С, highload-нагрузки, безопасность по 152-ФЗ.


Комментарии

Комментариев еще нет, Вы можете стать первым кто его оставит

Оставьте комментарий

На сайте используется система премодерирования комментариев, поэтому ваше сообщение будет опубликовано лишь после одобрения модератором

Вы отвечаете на комментарий пользователя

Отправить

ОБРАТНАЯ СВЯЗЬ

Напишите мне

Напишите мне, чтобы обсудить задачу. Работаю удаленно, официально (самозанятый), стоимость часа — 1800 ₽, в день могу уделять проекту 3–4 часа. Отвечаю быстро, консультация бесплатная.

Во время отправки произошла ошибка, пожалуйста попробуйте еще раз через некоторое время
Сообщение отправлено успешно

Телефоны

+7(993) 007-18-96

Email

info@tichiy.ru

Адрес

Россия, г. Москва

Отправляя форму Вы автоматически подтверждаете, что ознакомились и принимаете Политику конфиденциальности сайта