Tabs

In the editor you insert tabs with the Tab Group control in the toolbar (it moves into the More overflow menu on narrow windows). If your workspace has predefined tab groups configured, the control opens a menu: Custom Tab Group inserts a blank two-tab container, and picking a named group inserts its tabs ready-made, with their labels already filled in. This page is the format that control writes.

Leed’s own documentation defines three predefined groups — mcp-clients, operating-system and the reserved api-languages — so the tab strips you have been clicking since the first page of these docs are all instances of those three. Every other tab strip in this documentation set is a one-off container carrying its own page-scoped group id, which is a real and supported thing to do; the difference is explained under Predefined groups.

A tab container

Open a container with ===tabs-container, start each tab with @tab and its label, and close the container with === on a line of its own:

===tabs-container {data-tabs-groupId="md-tabs-basic"}

@tab First tab
Content for the first tab.

@tab Second tab
Content for the second tab.

===

How it renders

Content for the first tab.

The first tab is the one shown when the page loads. Everything between one @tab line and the next belongs to that tab.

LinePurposeAttributes land on
===tabs-containerOpens the container. Attributes go on this line, inside braces.The container <div>
@tab LabelStarts a tab. The text after @tab is the label.The label’s inner <span>
===Closes the container. Must be exactly three equals signs on their own line.Nothing — the closing line takes no attributes

What tabs contain

A tab holds any block content — code blocks, diagrams, math, images, lists, alerts, tables:

===tabs-container {data-tabs-groupId="md-tabs-content"}

@tab Python
```python
print("hello")

@tab TypeScript

console.log("hello");

===


Rendered: {data-id="ux76ikad"}

===tabs-container {data-id="crfi0907" data-tabs-groupId="md-tabs-content"}

@tab Python
```python
print("hello")

@tab TypeScript

console.log("hello");

===

The one thing a tab cannot contain is another tab container. That, and one typography quirk, are covered under Two things tabs cannot do.

Synced tab groups

Bind a container to a group with data-tabs-groupId, and picking a tab in one container switches every other container in the same group — on the page, and on every later page the reader visits:

===tabs-container {data-tabs-groupId="operating-system"}

@tab Linux
Linux instructions.

@tab Mac
Mac instructions.

@tab Windows
Windows instructions.

===

This container is bound to Leed’s operating-system group. Pick a different tab and every operating-system strip in these docs follows, including on the next page you open.

Two rules govern this, and both fail silently when you get them wrong.

Write the attribute with a capital I: data-tabs-groupId. The build matches that literal string when it looks the group up. Written data-tabs-groupid, the attribute still reaches the HTML and the browser still syncs the containers, because HTML attribute names are case-insensitive at runtime — but the build-time lookup misses, so the strip renders with your raw @tab text instead of the group’s display titles and with no icons. Nothing warns you; the tabs merely look unstyled.

Sync is positional, not by label. The browser stores a bare integer under leed-tabs-<groupId> in localStorage and replays it by index into every container in the group. Same group id and same labels is therefore necessary but not sufficient.

Predefined groups

A group id resolves through the documentation configuration: tabGroups[groupId][label.toLowerCase()], where label is the raw text you typed after @tab. The typed label is lower-cased before the lookup; the configured key is not normalized, so a key stored with a capital letter can never match anything. A miss renders the raw label with no icon, no warning and no build failure — a typo produces a tab that merely looks plain.

A matched entry supplies a display title and an icon, and nothing else:

FieldTypeEffectRead by the published site?
titlestring, requiredThe label the reader sees, replacing the typed @tab textYes
iconstringFont Awesome classes rendered as an <i> before the titleYes
iconClassstringAppended to icon on the same <i>Yes
iconStylestringEmitted verbatim as an inline style attribute on the <i>Yes
codeBlockLanguagestringSeeds each inserted tab with a code block in that languageNo — CMS editor only
ordernumberSorts the tabs the Tab Group control insertsNo — CMS editor only

Published tab order is source order. The tabs appear in the order your @tab lines appear, full stop. order sorts what the toolbar drops into the document when you insert a group; once the markdown exists, the markdown decides. Nothing on a published page depends on order or on codeBlockLanguage.

Leed’s documentation set defines three groups:

GroupTabsThe choice it represents
mcp-clientsClaude, Claude Code, ChatGPT, Cursorthe reader’s MCP client
operating-systemLinux, Mac, Windowsthe reader’s operating system
api-languagescURL, TypeScript, Python, Gothe reader’s language — reserved

That list is short on purpose. A predefined group is for a sticky identity the reader picks once and keeps for the whole documentation set. Anything per-task or comparative — preview versus live, image versus video, three layouts you are reading precisely in order to choose between — is not sticky, and syncing it across pages is a bug generator rather than a convenience. Those get an ad-hoc container with its own page-scoped id, or a table.

The trade an ad-hoc id makes is exactly one thing: only a configured group supplies titles and icons, so an ad-hoc container renders the raw @tab label with no glyph. That is by design, not a fault.

Where these groups are defined, and how to add one for your own set, belongs to Tab Groups for Consistent Examples; the surrounding documentation chrome the configuration sits in is Documentation Header, Footer and Logos.

The Tab Group toolbar control with its menu open, showing Custom Tab Group above the workspace's predefined groups

Two things in that menu are worth recognizing rather than reporting as bugs: the dropdown title-cases the group’s id, so operating-system reads “Operating System”, and api-languages is absent because it is filtered out by name.

Bind every container to a group id — even a one-off

Write data-tabs-groupId on every ===tabs-container you author, without exception. When the build matches a group id it records it and never clears it, so an unbound container that follows a bound one on the same page inherits the previous container’s titles and icons while emitting no group attribute of its own. The result looks grouped and does not sync — the worst of both.

Give a genuine one-off a descriptive id scoped to its page, the way this page’s examples use md-tabs-basic and md-tabs-content. It costs nothing, and it is the only way to be certain a container renders its own labels.

Where the tab icons come from

The Font Awesome classes in a group’s icon are Tailwind utilities, which means a class is compiled into the built stylesheet only if it appears somewhere the Tailwind build scans. The docs entry scans src, and JSON data files under src are scanned as text — which is precisely why a group configured in src/docs/docs.11tydata.json renders its icons.

The corollary is worth one sentence: a group that exists only in the CMS company record and never reaches a file under src emits an icon class with no CSS rule behind it, so the glyph is missing, with no error anywhere.

Tab label attributes

Attributes go after the label, in braces, and land on the label’s inner <span>:

@tab CLI {title="Using the command-line interface"}

A title written this way reaches the <span> verbatim, which is enough for the browser to show it as a hover tooltip. Only the key="value" form is read — .class and #id shorthand are discarded here, exactly as they are on tables, lists and blockquotes. The same applies to the container line: ===tabs-container {.wide} sets nothing, while ===tabs-container {class="wide"} merges wide into the container’s classes. Attributes explains why the accepted syntax differs by element.

A tab block's per-tab settings in the editor, with the label field and the group binding, over the rendered tab strip

Two things tabs cannot do

Tabs cannot nest

Tab panels are rendered by a second, internal markdown instance that has no tabs plugin loaded. An inner ===tabs-container is therefore consumed as ordinary content: its @tab lines become siblings of the outer group’s tabs, and a stray === paragraph is emitted where the inner container tried to close.

Authored:

===tabs-container {data-tabs-groupId="example"}

@tab Outer A

===tabs-container {data-tabs-groupId="inner"}

@tab Inner 1
Inner content.

===

@tab Outer B
Outer content.

===

What you get: a single group with three tabs — “Outer A”, “Inner 1” and “Outer B” — plus a paragraph containing ===. The editor does not let you build this in the first place: the Tab Group control is disabled while the cursor is inside a tab group. Use a collapsible section inside a tab when you need a second layer.

Smart typography is off inside panels

That same internal instance runs with the typographer disabled. Straight quotes stay straight, -- stays two hyphens, and (c) stays three characters inside a tab panel — while identical text outside one is converted. The difference is visible on this page:

Typed here: “quoted”, – dash, … ellipsis, ©. None of them are converted.

Outside the panel, the same four come out as “quoted”, – dash, … ellipsis, ©. The full substitution list is on Basic Formatting.

The markup readers get

A container renders as a wrapper holding a role="tablist" and a sibling block of panels. Everything is addressable from your own stylesheet:

ElementClass / roleNotes
Containerdiv.leed-tabs__containerCarries data-tabs-groupId and any class you merged into it
Tab-strip wrapperdiv.leed-tabs__wrapperLayout only
Tab stripul[role="tablist"].leed-tabsGains not-prose at runtime
Tabli[role="tab"].leed-tabs__itemCarries aria-selected, aria-controls, tabindex and a runtime data-tab-index
Panels wrapperdiv.leed-tabs__panelsLayout only
Paneldiv[role="tabpanel"]Toggled between block and hidden; labeled by its tab

Keyboard behavior comes for free: arrow keys move focus along the strip, Home and End jump to the ends, and Enter or Space activates the focused tab.

Where the page-type switch does and does not apply

tabGroup is one of nine editorFormattingOptions on a page type — and those gate posts-type page types only. On a documentation or api page type there is no switch to find and nothing to turn on: the toolbar shows every control, the server-side validator that checks markdown from the AI assistant and from MCP returns early without checking anything, and Settings does not even render the toggle block. The nine flags belong to blogs.

On a posts-type page type, tabGroup defaults to off, and markdown containing a tab container is rejected with a message naming the feature: This page type does not allow: tab groups. Allowed formatting features: code blocks. What each page type allows, and how to change it, is on What Each Page Type Lets You Format.

If you are emitting a tab container from a Handlebars template rather than from page content, the escaping rules are different again — see Template Formatting Reference.

ESC