Tab and Accordion Development on Vue.js for 1C-Bitrix
Tab components and accordions seem like a simple task until requirements arrive: preserve the active tab in the URL, lazily load tab content on first open, synchronize the state of multiple accordions on a page. Standard Bitrix templates implement tabs via jQuery .show()/.hide() — all content is rendered into the DOM at once, the URL is not updated, and deep-linking to a tab is impossible. Vue solves these problems without extra code.
Architecture of Vue Components for Bitrix
Tabs are built from a pair of components: TabGroup manages the active state, TabPanel renders the content of the active tab. The key decision is how to store the active tab:
-
ref()inside the component — when the URL is not needed -
useRoutefrom Vue Router — for SPA pages with full routing -
URLSearchParamsvia nativehistory.pushState— for Bitrix pages without Vue Router, when deep-linking is required
// Saving the active tab in the URL without Vue Router
const activeTab = ref(new URLSearchParams(location.search).get('tab') || 'description');
watch(activeTab, (tab) => {
const url = new URL(location.href);
url.searchParams.set('tab', tab);
history.replaceState({}, '', url);
});
This allows users to copy a link to a specific tab — critical for product pages with "Specifications", "Reviews", and "Delivery" tabs.
Accordions require smooth height animation, which is non-trivial in CSS: height: auto cannot be animated. The solution uses Vue's <Transition> with JavaScript hooks:
function onEnter(el) {
el.style.height = '0';
el.offsetHeight; // force reflow
el.style.height = el.scrollHeight + 'px';
}
function onAfterEnter(el) {
el.style.height = 'auto';
}
function onLeave(el) {
el.style.height = el.scrollHeight + 'px';
el.offsetHeight;
el.style.height = '0';
}
This delivers native height animation without libraries, working correctly regardless of the content inside.
Integration with Bitrix Data
Tab content from infoblocks. A product page with tabs: "Description" — DETAIL_TEXT from b_iblock_element, "Specifications" — properties from b_iblock_element_property, "Reviews" — data from the reviews infoblock. In result_modifier.php an array is assembled:
$arResult['TABS'] = [
['id' => 'description', 'title' => 'Description', 'content' => $arResult['DETAIL_TEXT']],
['id' => 'props', 'title' => 'Specifications', 'props' => $arResult['DISPLAY_PROPERTIES']],
['id' => 'reviews', 'title' => 'Reviews', 'lazy' => true, 'endpoint' => '/api/reviews/?id=' . $arResult['ID']],
];
The "Reviews" tab is flagged with lazy: true — content is loaded via AJAX only on first open. The component's loading state shows a spinner during the request.
FAQ accordion from an infoblock. The bitrix:news.list component template outputs questions and answers. The Vue component mounts on the container, parsing the existing HTML via querySelectorAll (progressive enhancement) or receiving data via data-items JSON.
Case: Pricing Page Tabs with Dynamic Period Switching
A SaaS product on Bitrix, a pricing page with "Monthly" / "Annual" tabs. When switching tabs, prices on all plan cards must change without a page reload.
The jQuery solution required duplicating all the HTML with prices — one set for monthly prices, another for annual, then toggling visibility. With 6 plans — 12 HTML blocks, hard to maintain.
The Vue solution: a single PricingTabs component, data from window.BX_STATE.pricing:
const period = ref('monthly'); // 'monthly' | 'yearly'
const plans = computed(() =>
rawPlans.value.map(plan => ({
...plan,
price: period.value === 'yearly'
? Math.round(plan.yearlyPrice / 12)
: plan.monthlyPrice,
badge: period.value === 'yearly' ? `Save ${plan.yearlySaving}%` : null
}))
);
Prices are stored in JSON and computed reactively when the period changes. Price-change animation — <Transition name="price-flip"> with CSS rotateX. Data in PHP is populated from the "Plans" infoblock with MONTHLY_PRICE and YEARLY_PRICE fields. Development — 2 days.
Accessibility and SEO
Tabs without ARIA is a common problem. Correct markup:
<div role="tablist">
<button role="tab" :aria-selected="activeTab === 'desc'" :aria-controls="'panel-desc'">
Description
</button>
</div>
<div role="tabpanel" id="panel-desc" :hidden="activeTab !== 'desc'">
<!-- content -->
</div>
The hidden attribute instead of v-show with display: none — screen readers correctly ignore hidden panels. Keyboard navigation between tabs — ArrowLeft/ArrowRight via onKeydown.
SEO aspect: search engines index tab content if it is present in the HTML. With v-if (lazy rendering) — the first tab is rendered server-side via the Bitrix template, the rest are loaded via AJAX. This is acceptable for Googlebot — it executes JavaScript, but with a delay. For SEO-critical content (product specifications) — always render in HTML, hide via hidden.
Scope of Work
| Component | Estimated timeline |
|---|---|
| Tabs with static content + URL persistence | 1-2 days |
| Tabs with lazy-loaded content | 2-3 days |
| Accordion with animation | 1 day |
| Linked tabs + dynamic data (like the pricing example) | 3-5 days |
Timelines are determined by the number of data sources, animation requirements, and the complexity of Bitrix component integration.







