Standardizing Swiper and Layouts in Bitrix: Moving Away from Inline Script Chaos
Rus
Eng
Стандартизация Swiper и макетов в Битриксе: уходим от хаоса в inline-скриптах
I solve complex technical challenges for Bitrix and 1C—ranging from auditing existing code to website development and configuring complex integrations—with a guarantee of results.
Recommended

Website hosting

If you are choosing hosting for your project, I recommend Timeweb. Modern infrastructure, a wide range of plans from starter options to high-load VPS, and responsive tech support that actually helps instead of sending template replies. I host some of my own projects there and recommend it to my clients.

Go to site Affiliate link. The price remains the same for you.

All English-language materials have been translated fully automatically using the Google service

The Problem: Inline Scripts and the Illusion of Control

In most 1C-Bitrix projects Swiper initialization looks the same. In each component's template.php there's its own inline script:

new Swiper('.card', {
  slidesPerView: 3,
  spaceBetween: 20,
  pagination: { el: '.swiper-pagination' },
});

This works exactly until the first AJAX request. Pagination, filter, cart update — new slides appear in the DOM, but Swiper doesn't know about them. Typical "fixes":

  • wrap initialization in setTimeout(500) — guessing when the DOM appears;
  • call swiper.update() — doesn't help if the slider was never created;
  • duplicate the inline script into every component with AJAX — copy-paste grows with the project.

The root cause isn't Swiper, it's the architecture. Initialization is tied to the moment HTML is rendered, not to the moment the element appears in the DOM. As long as those two moments coincide, everything works. As soon as Bitrix starts loading blocks asynchronously, the coincidence breaks.

The second source of problems is configuration inside the script. Slider settings are spread across dozens of files: loop: true here, speed: 0 there, a wrong pagination selector somewhere else. Figuring out how a given slider on a page is configured means grepping the codebase.

Architecture: Three Levels of Configuration

Instead of scattered inline scripts, a single scheme with three levels is proposed. Each level overrides the previous one:

LevelWhere it's setWhat it definesExample
GlobalswiperConfig object in the main scriptBase settings for every sliderspeed: 300, slidesPerView: 1
Componentdata-options attribute in HTMLOverrides for a specific sliderloop: false, effect: 'slide'
ImperativeJS in component_epilog.phpSettings for complex casesThumbs sync, custom events

This scheme is a production standard. Markup describes the structure, attributes describe configuration, JS only intervenes where attributes aren't enough.

Why data-options, Not a Set of data-*

Two approaches to attribute configuration:

  • Flat attributes: data-slides-per-view="3", data-space-between="20", data-loop="true".
  • One JSON: <div data-options='{"slidesPerView": 3}'>.

JSON wins for Bitrix. The reason lies in the nature of template.php: settings are born as a PHP array and passed to json_encode() as a single line. Flat attributes would require manual mapping of every key. Plus, nested structures — breakpoints, pagination.el — simply can't be expressed through data-*.

Breakpoints in data-options

Slider responsiveness is exactly the case where flat attributes fail. Swiper handles this with the breakpoints object, where the key is the minimum viewport width and the value is a set of overrides. Through data-* it's expressed as a dozen redundant attributes; through data-options it's one block.

<div class="swiper card" data-options='<?=swiperDataOptions([
    'slidesPerView' => 1,
    'spaceBetween' => 10,
    'breakpoints' => [
        768 => [
            'slidesPerView' => 2,
            'spaceBetween' => 15,
        ],
        1024 => [
            'slidesPerView' => 3,
            'spaceBetween' => 20,
        ],
    ],
])?>'>
    <div class="swiper-wrapper">
        <div class="swiper-slide">…</div>
    </div>
</div>

The manager just passes this object to Swiper, no extra handling required.

The SwiperManager Class

Initialization, reinitialization, and option parsing are best gathered into a single class. This gives a namespace, a single entry point, and convenience for external calls from component_epilog.php.

class SwiperManager {
    constructor(config = {}) {
        this.config = Object.assign({
            slidesPerView: 1,
            spaceBetween: 0,
            speed: 300,
            watchOverflow: true,
            observer: true,
            observeParents: true,
        }, config);

        this.instances = new WeakMap();
        this.observer = null;
        this.destroyed = false;

        this._onReady = this._onReady.bind(this);
        this._onAjaxSuccess = this._onAjaxSuccess.bind(this);
        this._onMutation = this._onMutation.bind(this);

        this._bindEvents();
    }

    /**
     * Attaches handlers: DOMContentLoaded, Bitrix AJAX, MutationObserver.
     * Observation targets documentElement rather than document.body — if the
     * script is included in head, body doesn't exist yet at that point.
     *
     * @returns {void}
     */
    _bindEvents() {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', this._onReady, { once: true });
        } else {
            this._onReady();
        }

        if (typeof BX !== 'undefined') {
            BX.ready(() => {
                BX.addCustomEvent('onAjaxSuccess', this._onAjaxSuccess);
            });
        }

        this.observer = new MutationObserver(this._onMutation);
        this.observer.observe(document.documentElement, { childList: true, subtree: true });
    }

    /**
     * Initializes all not-yet-initialized sliders inside the container.
     * The :not(.swiper-initialized) selector relies on the class Swiper adds
     * to the root element after successful creation. That's what makes
     * repeated init() calls safe.
     *
     * @param {Element|Document} container — search scope.
     * @param {Object} [override={}] — params on top of data-options.
     * @returns {void}
     */
    init(container = document, override = {}) {
        if (this.destroyed) return;

        const nodes = container.querySelectorAll('.swiper:not(.swiper-initialized):not(.single)');
        if (!nodes.length) return;

        nodes.forEach((el) => {
            const params = this._buildParams(el, override);

            try {
                const instance = new Swiper(el, params);
                this.instances.set(el, instance);
            } catch (e) {
                console.error('[SwiperManager] Failed to create Swiper:', el, e);
            }
        });
    }

    /**
     * Destroys the existing instance on the element and creates a new one.
     *
     * @param {Element} el — slider root element.
     * @param {Object} [override={}] — params on top of data-options.
     * @returns {Swiper|null}
     */
    reinit(el, override = {}) {
        if (!el || this.destroyed) return null;

        const instance = this.instances.get(el);
        if (instance) {
            try {
                instance.destroy(true, true);
            } catch (e) {
                console.warn('[SwiperManager] destroy error:', e);
            }
            this.instances.delete(el);
        }

        el.classList.remove('swiper-initialized');

        const params = this._buildParams(el, override);

        try {
            const fresh = new Swiper(el, params);
            this.instances.set(el, fresh);
            return fresh;
        } catch (e) {
            console.error('[SwiperManager] Failed to recreate Swiper:', el, e);
            return null;
        }
    }

    /**
     * Returns the Swiper instance for an element.
     *
     * @param {Element} el
     * @returns {Swiper|undefined}
     */
    get(el) {
        return this.instances.get(el);
    }

    /**
     * Disconnects the observer and marks the manager destroyed.
     * Needed if the manager is created and destroyed dynamically.
     *
     * @returns {void}
     */
    destroy() {
        if (this.observer) {
            this.observer.disconnect();
            this.observer = null;
        }

        this.destroyed = true;
    }

    /**
     * Merges the global config, data-options, and override.
     *
     * @private
     */
    _buildParams(el, override) {
        return Object.assign({}, this.config, this._parseOptions(el), override);
    }

    /**
     * Safely parses data-options.
     *
     * @private
     */
    _parseOptions(el) {
        const raw = el.dataset.options;
        if (!raw) return {};

        try {
            return JSON.parse(raw);
        } catch (e) {
            console.warn('[SwiperManager] Invalid data-options:', el, e);
            return {};
        }
    }

    _onReady() {
        this.init();
    }

    _onAjaxSuccess() {
        setTimeout(() => this.init(), 100);
    }

    /**
     * Reacts to added DOM nodes. The scan is limited to mutation.target,
     * not the whole document — this matters on an active DOM.
     *
     * @private
     */
    _onMutation(mutations) {
        if (this.destroyed) return;

        const targets = new Set();

        for (const mutation of mutations) {
            if (!mutation.addedNodes.length) continue;

            const node = mutation.target instanceof Element
                ? mutation.target
                : mutation.target.parentElement;

            if (node) {
                targets.add(node);
            }
        }

        targets.forEach((target) => {
            this.init(target);
        });
    }
}

Key points:

  • WeakMap stores element → instance mappings. This lets you call destroy() on one slider without touching its neighbors.
  • _buildParams guarantees priority: global config → data-options → override. A predictable chain instead of scattered Object.assign calls across components.
  • watchOverflow: true in the base config hides a slider when it has fewer slides than slidesPerView. Without this option an empty slider looks broken.
  • MutationObserver catches cases onAjaxSuccess misses: insertions via innerHTML, third-party library components, dynamic modals.
  • Observation targets document.documentElement, not document.body. If the script is included in <head>, document.body is still null at that moment and observer.observe() would throw.
  • The swiper-initialized class is internal — Swiper adds it after successful creation. The :not(.swiper-initialized) selector relies on it, which is what makes repeated init() calls idempotent.
  • No forced el.style.display = 'block'. A slider can be intentionally hidden — for instance, until an accordion expands. The manager has no right to change that state.
  • try/catch around new Swiper(): an invalid selector, a missing module, a conflict with another plugin — all get logged but don't crash the other sliders on the page.
  • The destroy() method disconnects the observer. If the manager is created dynamically (e.g., during an SPA transition), skipping it leaves a dangling MutationObserver.

Bootstrapping

window.swiperManager = new SwiperManager({
    speed: 400,
    slidesPerView: 1,
});

From there on it's a single access point to every slider on the page. No window.reloadCatalog, no "don't forget to call it in the new component."

Handling AJAX and Dynamic Content

The classic "onAjaxSuccess + setTimeout" scheme covers only requests triggered by Bitrix. It's enough until the project leaves the boundaries of the stock bitrix:news.list. Then cases it can't see appear:

  • a modal whose content is inserted via innerHTML;
  • a third-party library component (Vue, React, Alpine) that rendered after AJAX;
  • a slider inside a block added by a custom script.

The MutationObserver in the class closes these cases. Repeated init() calls are safe thanks to the :not(.swiper-initialized) selector: already-created sliders are skipped. This eliminates the main risk of the observer — initializing one element multiple times.

The onAjaxSuccess event remains as a fallback. The setTimeout(100) delay is needed because Bitrix fires the event before inserting HTML into the DOM. Removing it entirely isn't possible — you'd have to wait for MutationObserver, adding an extra render cycle.

Performance

MutationObserver on documentElement is a global observer, and a naive implementation turns it into a bottleneck: every DOM change triggers a full document scan. Three rules apply.

The scan is limited to mutation.target, not document. When Bitrix loads a block via AJAX, mutation.target is the container the HTML was inserted into. Sliders need to be searched only there, not across the whole page. On a catalog with 500 cards, that's the difference between querySelectorAll over 500 elements and over a single block.

One observer per page, not per component. SwiperManager is created once, in the site template's main script. If you create it in every component_epilog.php, the page ends up with as many observers as there are components with sliders, and each receives its own copy of every DOM mutation.

Batch processing via Set. If ten mutations arrive in different nodes within one tick, _onMutation collects them into a Set of unique targets and calls init() once per unique target, not ten times in a row on the same container.

It's also worth remembering Swiper itself: every instance registers its own event listeners and holds references to the DOM. If you have fifty sliders on a page, that's fifty objects with requestAnimationFrame inside. watchOverflow: true in the base config hides empty sliders but doesn't prevent them from being created. If a list is known to have many items without slides, better not to put the .swiper class in the template at all.

Handling "Complex" Sliders

Not every slider fits a unified config. Thumbs sync, custom events, binding to other components — such cases are excluded from automatic initialization by the .single class. Responsibility for creating them shifts to the component code.

Important: if an element has class .swiper and no .single, the manager will initialize it on DOMContentLoaded itself. If the developer then calls reinit(), the instance is destroyed and recreated — extra work. That's why in the examples below, sliders controlled by the component must carry .single.

To attach a script from component_epilog.php, use Asset::addJs():

use Bitrix\Main\Page\Asset;

Asset::addJs('/local/templates/.default/js/product-gallery.js');

Contents of product-gallery.js:

document.addEventListener('DOMContentLoaded', () => {
    const gallery = document.querySelector('#productGallery');
    const thumbs = document.querySelector('#productThumbs');

    if (!gallery || !thumbs) return;

    const thumbsInstance = new Swiper(thumbs, {
        slidesPerView: 4,
        spaceBetween: 10,
        watchSlidesProgress: true,
    });

    window.swiperManager.reinit(gallery, {
        thumbs: { swiper: thumbsInstance },
    });
});

In the markup:

<div id="productGallery" class="swiper single">…</div>
<div id="productThumbs" class="swiper single">…</div>

The contract is simple: if a developer adds the .single class, they must call reinit() themselves. In exchange — full freedom in configuration without the risk of the manager interfering and overriding settings.

Nested Sliders and Selector Conflicts

A separate case is a slider inside a slider. For example, a product gallery opens in a modal, and inside the gallery there's a mini-slider with brand logos. If both elements carry the .swiper class without .single, the manager initializes both, but when it recreates the outer one it destroys and recreates its own instance without touching the inner one. This is fine as long as their settings don't conflict.

The problem starts when selectors in options are written without context. Example of a bad data-options:

{
  "pagination": { "el": ".swiper-pagination" },
  "navigation": { "nextEl": ".swiper-button-next" }
}

Inside the outer slider there's both its own .swiper-pagination and the inner slider's. Swiper takes the first matching element in the document and binds the outer slider's pagination to the inner slider's node. Visually this looks like "pagination doesn't work" or "slides duplicate."

The right way is to bind elements to the specific slider via data-* attributes inside the container:

<div class="swiper card" data-options='<?=swiperDataOptions([
    "pagination" => ["el" => "[data-swiper-pagination]"],
    "navigation" => [
        "nextEl" => "[data-swiper-next]",
        "prevEl" => "[data-swiper-prev]",
    ],
])?>'>
    <div class="swiper-wrapper">…</div>
    <div class="swiper-pagination" data-swiper-pagination></div>
    <button type="button" class="swiper-button-next" data-swiper-next aria-label="Next slide"></button>
    <button type="button" class="swiper-button-prev" data-swiper-prev aria-label="Previous slide"></button>
</div>

The [data-swiper-pagination] selector stays unique inside the component, even if the page has thirty sliders with the same markup. For nested cases that need explicit exclusion, additionally put .single on the inner one and manage it from code.

Lifecycle: destroy Instead of reInit

The reInit() method existed in older Swiper versions and has been removed in current ones. The reason is unpredictable behavior when the slide set changes: loop-mode clones remain, cached sizes go stale, event listener references get orphaned. The only correct path is destroy() + a new instance.

const instance = window.swiperManager.get(el);

if (instance) {
    instance.destroy(true, true);
}

Two destroy arguments matter:

  • deleteInstance = true — removes el.swiper and associated data;
  • cleanStyles = true — removes inline styles added by Swiper during initialization.

Without cleanStyles = true, styles stack up on re-initialization: slides jump, container width doesn't recalculate, loop duplicates already-existing clones. That's exactly why "reInit() doesn't work" — it simply doesn't remove what needs removing.

Marquee with Hold-to-Drag

An infinite marquee with hold-to-drag is a task where standard Swiper settings conflict with each other. Let's go point by point.

Be Careful with freeMode

The first instinct is to enable freeMode: true and autoplay with delay: 0. For many configurations this does work: freeMode removes fixed slide positions and zero delay gives continuous motion. But behavior depends on the Swiper version and on how autoplay-stop-on-interaction is implemented. In some builds freeMode starts intercepting drag events and stops autoplay even with disableOnInteraction: false; in others it doesn't.

A universally safe marquee configuration is without freeMode, but with loop, a long speed, and delay: 0:

const marqueeConfig = {
    slidesPerView: 'auto',
    spaceBetween: 20,
    loop: true,
    speed: 6000,
    allowTouchMove: true,
    simulateTouch: true,
    grabCursor: false,
    autoplay: {
        delay: 0,
        disableOnInteraction: false,
        pauseOnMouseEnter: false,
    },
};

If the project already relies on freeMode: true and works stably — don't touch it. If behavior is flaky — remove freeMode, as in the example above.

delay: 0 and a long speed give continuous motion. But that alone isn't enough: by default Swiper uses an eased transition function, so the marquee "pulses" — each slide slows down and speeds up again. Fixed by one line of CSS, loaded via Asset::addCss() or the template's styles.css:

.swiper-marquee .swiper-wrapper {
    transition-timing-function: linear !important;
}

If the project must support users with prefers-reduced-motion: reduce, it's worth adding a safeguard:

@media (prefers-reduced-motion: reduce) {
    .swiper-marquee .swiper-wrapper {
        transition-duration: 0.01ms !important;
    }
}

This doesn't turn off the marquee but removes the long transition animation, leaving an instantaneous frame change — an acceptable degradation for users with vestibular disorders.

Drag Threshold and Interactive Elements

Swiper starts a drag on the slightest cursor shift. A user clicks a link inside a slide, the hand twitches 2–3 pixels — the drag triggers, the link doesn't open. Fixed by combining a threshold with temporary disabling of allowTouchMove over interactive elements.

The key point: mouseover/mouseout don't cover touch devices — those events either never arrive or arrive with a delay. The correct approach is pointerdown, which fires for mouse, finger, and stylus alike. We temporarily disable allowTouchMove at the moment of pressing and restore it on pointerup or pointercancel.

const marqueeEl = document.querySelector('.swiper-marquee');

const marqueeSwiper = window.swiperManager.reinit(marqueeEl, {
    ...marqueeConfig,
    threshold: 10,
});

marqueeEl.addEventListener('pointerdown', (e) => {
    const interactive = e.target.closest('a, button, input, select, textarea, [role="button"]');
    marqueeSwiper.allowTouchMove = !interactive;
});

marqueeEl.addEventListener('pointerup', () => {
    marqueeSwiper.allowTouchMove = true;
});

marqueeEl.addEventListener('pointercancel', () => {
    marqueeSwiper.allowTouchMove = true;
});

threshold: 10 means: a drag starts only after the cursor has moved 10 pixels. That's enough for clicks — a short tap won't become a drag. On touch devices the threshold acts as the system gesture recognition boundary, and pointerdown with an interactive-element check disables the drag before it even begins.

It's worth remembering that allowTouchMove is a plain property of the Swiper instance. If it toggles quickly and often, "stuck" states can remain: the user pressed a button, the browser didn't send pointerup (for example, due to a zoom gesture). In such cases it helps to reset state on pointerleave as well.

Accessibility

A slider that looks good but is unusable with a keyboard is half a slider. Swiper has a built-in a11y module, enabled by an option, that sets most of the needed attributes automatically.

{
  "a11y": {
    "enabled": true,
    "prevSlideMessage": "Previous slide",
    "nextSlideMessage": "Next slide",
    "firstSlideMessage": "This is the first slide",
    "lastSlideMessage": "This is the last slide",
    "paginationBulletMessage": "Go to slide {{index}}"
  },
  "keyboard": {
    "enabled": true,
    "onlyInViewport": true
  }
}

What this gives you:

  • the root container gets role="region" and aria-roledescription="carousel";
  • each slide gets role="group", aria-roledescription="slide", and an aria-label with its position;
  • navigation buttons and pagination bullets get aria-label and proper tabindex;
  • the ← and → keys change slides when focus is inside the slider;
  • screen readers announce which slide the user is on.

Navigation buttons need extra care if they're custom:

<button type="button" class="swiper-button-prev" data-swiper-prev aria-label="Previous slide">
    <svg aria-hidden="true"><use href="/local/templates/.default/img/sprite.svg#arrow-left"></use></svg>
</button>

The icon itself carries aria-hidden="true" so screen readers don't duplicate the button name with the sprite name. For decorative elements inside a slide — alt="" on images that carry no meaning. For informative ones — alt with a product description.

Configuration in the Component Template

The PHP side changes minimally. The only improvement is to move json_encode into a helper so escaping parameters aren't duplicated in every template.php. An obligatory condition — wrap the declaration in function_exists, otherwise a page with two templates gets a fatal error from redeclaring the function.

if (!function_exists('swiperDataOptions')) {
    /**
     * Prepares a JSON string for the data-options attribute.
     *
     * @param array $options Swiper parameters
     * @return string Escaped JSON
     */
    function swiperDataOptions(array $options): string
    {
        return htmlspecialchars(
            json_encode($options, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
            ENT_QUOTES,
            'UTF-8'
        );
    }
}

The helper goes into the site template's functions.php so it's available from any component without additional require.

Example usage in a component template:

<div class="swiper card" data-options='<?=swiperDataOptions([
    'pagination' => ['el' => '[data-swiper-pagination]'],
    'loop' => false,
    'effect' => 'slide',
    'speed' => 0,
])?>'>
    <div class="swiper-wrapper">
        <?foreach ($morePhoto as $IMAGE):?>
            <div class="swiper-slide">
                <img src="<?=htmlspecialcharsbx($IMAGE)?>" alt="<?=htmlspecialcharsbx($productTitle)?>">
            </div>
        <?endforeach;?>
    </div>
    <div class="swiper-pagination" data-swiper-pagination></div>
</div>

About the json_encode flags:

  • ENT_QUOTES — protects the attribute from breaking if the JSON contains single or double quotes, or < and >. Without it, the first title with an apostrophe would tear the markup apart.
  • JSON_UNESCAPED_UNICODE — keeps Russian strings readable during DevTools debugging.
  • JSON_UNESCAPED_SLASHES — useful for URLs. Without it the string https://domain.ru/ becomes https:\/\/domain.ru\/ — visually noisy and harder to read. This flag has nothing to do with selectors like .swiper-pagination.

Error Handling and Logging

Errors inside SwiperManager shouldn't bring down the whole page. The rules are simple.

Instance creation and recreation — inside try/catch. An invalid selector, a missing module, a conflict with another plugin that already attached its own handlers to the same element — all get logged via console.error and let the other sliders on the page initialize normally.

Parsing data-options — inside try/catch with a warning. If a developer forgot a comma in the JSON, the user sees a slider without settings rather than a blank page. The console gets a console.warn pointing at the specific element, which immediately localizes the problem.

Errors inside destroy() aren't critical but are logged. If the instance was already destroyed or Swiper wasn't fully initialized at the moment destroy is called, the exception shouldn't interrupt reinit().

For production it makes sense to replace console.* with a custom logger that has levels and sends critical errors to a monitoring system. But that's a separate task, beyond the scope of this article.

Testing

Three scenarios that must be checked manually before shipping.

Catalog with AJAX pagination. Open the catalog, page through three or four pages. After each load, sliders in the cards should be initialized, navigation buttons working, and the console should have no errors. Also check in the Performance tab that the MutationObserver count doesn't grow and that each pagination adds one init call, not ten.

Modal with a slider. Open the modal, close it, reopen five times. A new instance should be created inside the modal, and the old one destroyed. Check via window.swiperManager.get(el): after closing the modal the method should return either a new instance (on reopen) or undefined (if the DOM was removed). If it returns an old instance with dead references — destroy() was skipped somewhere.

Marquee with drag. Open the marquee page on desktop and mobile. On desktop — verify that clicking a link inside a slide opens the link rather than triggering a drag. On mobile — verify that a short tap on a link doesn't become a drag, and that long-press-and-drag works. DevTools has a touch-event emulation mode: More tools → Sensors → Touch.

It's also worth testing under a slow network: throttle to Slow 3G, reload the page, and verify that sliders initialize after full load, not with empty containers. If the page renders before Swiper finishes loading, the MutationObserver should pick up the changes and initialize sliders again.

Wrap-Up

The standard approach — an inline script in each component, scattered config, reacting to AJAX via setTimeout — works while the project is small. Once dynamics appear, it turns into a pile of hacks. The SwiperManager class changes the viewpoint: a slider isn't a part of the template, it's an object with a lifecycle. One manager creates, recreates, and destroys it; the markup only describes configuration through data-options.

The result is three levels of configuration instead of scattered scripts, a MutationObserver scoped to mutation.target instead of a full-document scan, a public API for complex cases instead of copy-paste, an explicit lifecycle via destroy(true, true), and accessibility through the built-in a11y module. The same Swiper, the same markup — but under control.

Aleksandr Tichiy
Author

Aleksandr Tichiy

Fullstack developer · 1C-Bitrix

I build and modernize online stores and corporate portals on 1C-Bitrix. Integrations with 1C, high-load performance, 152-FZ compliance. Writing about architecture and e-commerce.


Comments

There are no comments yet, you can be the first to leave it

Leave a comment

The site uses a comment pre-moderation system, so your message will be published only after approval by the moderator

You are replying to a user's comment

Send

FEEDBACK

Email me

Please message me to discuss the task. I work remotely and officially (as a self-employed professional); my hourly rate is 1,800 RUB, and I can dedicate 3–4 hours a day to the project. I respond quickly, and the consultation is free.

An error occurred while sending, please try again after a while
Message sent successfully

Phones

+7(993) 007-18-96

Email

info@tichiy.ru

Address

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

By submitting the form, you automatically confirm that you have read and accept the Privacy Policy site