Ambience buro
Website for a full-cycle architecture and interior design bureau
Role
Frontend/Fullstack Developer (solo, full cycle)
Year
2021
Stack
FigmaHTMLSCSSJavaScriptGulpWebpackGoogle Maps JS APIPHPWordPress

The task
A full-cycle bureau: house architecture, interiors for apartments and commercial spaces, private and corporate clients. The starting point was a brief and a logo — no predecessor site, no design system.
The key requirement came from the client: use a monochrome scheme to make the completed projects stand out. The interface must not compete with the photography of the spaces — clean lines, delicate typography, minimal decoration. On top of that: a portfolio where a project can be judged without opening its page; a project template with floor plans and galleries; three service pages; a blog built with E-E-A-T in mind. All of it on WordPress, so the bureau could run its portfolio and articles without a developer.
Stages
Analysis and unpacking the brief
- Working through the page-by-page brief: target audience, value proposition, five reference sites with notes on what exactly appealed
- Designing the site map: home, portfolio, project page, three service pages, about, blog, contacts
- Translating client wishes into technical requirements — "let the header switch to the colour opposite to the section" became a dedicated ScrollTrigger module
Design concept in Figma
- Monochrome scheme, delicate typography: Manrope 200 for body copy, Ysabeau SC for headings
- Minimal graphic elements — the visual weight was deliberately handed to the project photography
- Container width revised from 1690 to 1240 px after review: the layout got denser and the project shots got larger relative to the margins
Markup and build
- Custom pipeline: Gulp orchestrates Webpack, followed by asset post-processing
- Mobile-first as a hard rule, not as an intention
- HTML assembled from partials — six pages that are pure composition of blocks
Animation and interaction
- GSAP ScrollTrigger: context-aware header, pinned services stack, pinned advantage blocks
- Swiper: portfolio card that cycles through frames on hover
- lightGallery paired with a justified layout for the project galleries
- Click-to-load facade for YouTube, two Google Maps instances, DOM node relocation into the mobile menu
WordPress integration
- Content model on ACF: project fields, article fields, options page
- 12 page templates on top of a shared header and footer
- AJAX filtering for portfolio and blog with state preserved in the address bar
- Contact Form 7 forms with a Yandex.Metrica goal on successful submission
SEO, E-E-A-T and launch
- JSON-LD for the organisation, plus a
FAQPagegenerated from a content field - Article author block, "Read also", breadcrumbs across all sections
- Custom cookie-consent bar
- Meta tags
The process
Implementation
Design system in SCSS
Tokens live in a single place: $mainFont / $titleFont, the #000 / #fff / #747474 palette, $maxWidthContainer: 1240 plus 30 px gutters.
The container is implemented through an attribute selector, [class*='__container']: any block that names its inner element by the BEM convention automatically gets the grid and the margins — no utility class in the markup, no mixin repeated in every file.
Mobile-first without exceptions
295 min-width media queries against 8 max-width — the base is written for mobile and desktop is layered on top. The scale has three steps: base (< 768) → ≥ 768 → ≥ 992, with no in-between workarounds.
Every :hover state is wrapped in @media (any-hover: hover), so on touch devices hover does not stick after a tap — a common annoyance in catalogues with large cards.
Project card that cycles through frames
The central interaction idea of the portfolio: a card should show the project, not a single cover image.
Inside each card sits a Swiper with effect: 'fade'. On desktop, autoplay is stopped right inside the init handler, and from then on the slider is driven purely by the pointer: mouseenter sets the delay to 100 ms and starts playback, slideChange raises the delay to 800 ms, mouseleave stops it. As a result the first frame changes almost instantly — the user gets immediate feedback — and after that the frames settle into a calm rhythm instead of flickering. On touch devices the branch inverts: there is no hover, so autoplay simply runs on its own.
The frames are not curated by hand: they are the post's cover image plus the first three images of the project gallery. A content manager fills in one gallery, and the previews in three places across the site are assembled from it automatically.
Context-aware header on ScrollTrigger
Sections are marked with the classes _dark / _light / _transparent. A ScrollTrigger is created for each one, with its boundaries offset by the height of the header (start: 'top-=${headerHeight} top') — the switch happens exactly when the header physically enters a new section, not when the section's top edge reaches the viewport. The header flips to the theme that contrasts with the section, and the transition is smooth.
Layered on top of that is independent scroll behaviour: the background appears once the user leaves the first screen, and the header hides on downward movement and returns on upward movement. Both mechanisms read the position through one shared scroll listener rather than registering their own.
Pinned services stack
Each service card is pinned for the height of the viewport and cross-fades on opacity tied to scroll position via scrub — the cards stack on top of one another. The first and last items are handled by separate branches so the stack starts cleanly and releases the page cleanly on exit.
Project gallery
A project has three independent galleries with their own captions — typically renderings, photos of the finished space, and details. Any of them can be left empty and the template handles it.
The layout is a justified grid: rows are aligned to a common height (532 px on desktop, 400 on mobile) while the original aspect ratios of the frames are preserved. For interior photography, where landscape shots, verticals and panoramas are mixed together, this works better than a rigid grid — nothing gets cropped through the centre.
Viewing is handled by lightGallery, built with only the two plugins actually needed (thumbnails and fullscreen); the library's remaining modules are simply never imported and never reach the bundle.
Content model on ACF
A project is described by fields: city, year, floor area, tagline, detailed description, floor plan image, and three galleries with captions. An article has a preview text and a FAQ repeater. The phone number is moved to an options page so it changes in one place.
Post tags are used as the filtering axis for the portfolio — the type of property (apartments, houses, commercial). The filter list is not hardcoded anywhere: it is assembled from the tags of actually published projects, so introducing a new property type requires no code changes.
AJAX filtering with URL state
Two filters — portfolio by property type and blog by article subcategory. The WordPress-side handlers return ready-made card markup; the client swaps the contents of the list and shows a loader for the duration of the request.
The important part is that the state lives in the address bar. history.pushState writes ?tag=, so a link to the "Apartments" selection can be shared and it opens already filtered: on a cold load the parameter is read from URLSearchParams, highlights the right chip and repeats the request. The filter stops being the state of a single tab and becomes addressable.
A bridge between AJAX and the sliders
Every card loaded in has its own Swiper that needs initialising after insertion into the DOM. Instead of calling it directly from the request handler, the code dispatches a custom DOM event, ajaxContentLoaded, which the slider module subscribes to.
As a result the request layer and the Webpack module layer know nothing about each other: the filter does not import Swiper, and the sliders do not know the filter exists. Adding another subscriber to "the content has updated" is one line, with no changes to the filter.
Lazy loading of previews
A custom IntersectionObserver for the portfolio grid: the card's src holds a lightweight thumbnail while the full-size image sits in data-src and is substituted as the card approaches the viewport. The first screen of the catalogue loads as thumbnails and the full frames are pulled in as the user scrolls. There is a branch for browsers without IntersectionObserver — there the substitution happens immediately.
E-E-A-T in the article template
The author block is assembled from the WordPress profile: avatar, name, role — an authorship signal for search engines and simply an honest byline on the material.
The FAQ lives in a single ACF repeater and is rendered twice: as an accordion for the reader and as JSON-LD FAQPage for the search engine. The markup is produced through json_encode with the JSON_UNESCAPED_UNICODE flag so that Cyrillic does not turn into \u escape sequences. The editor fills in the questions once — the structured data appears on its own.
The "Read also" block picks up material by shared categories, and the excerpt is cut at a sentence boundary by a regular expression rather than by character count. A small thing, but it removes the mid-word truncations that give a templated site away.
Maps
Two instances of the Google Maps JS API: a presence map with markers across seven cities and its own zoom level for mobile, and an office map on the contacts page. Styling is moved into a cloud-based mapId, so the map's appearance changes without rebuilding the frontend. The markers are AdvancedMarkerElement with a custom SVG instead of the default pin.
Initialisation only happens if the map container is present on the page, and the libraries are pulled in through Promise.all(importLibrary(...)) — on pages without a map, Google Maps is never loaded at all.
The results
six static page types expanded into twelve WordPress templates on top of a shared header and footer
SCSS blocks in a per-block architecture
AJAX filters with state in the URL — a link to a selection can be shared and opens ready