diff --git a/apps/test/content/posts/code-tabs-test.md b/apps/test/content/posts/code-tabs-test.md
new file mode 100644
index 00000000..8f3c1e7a
--- /dev/null
+++ b/apps/test/content/posts/code-tabs-test.md
@@ -0,0 +1,110 @@
+---
+title: Code Tabs Test
+date: 2026-01-25T07:50:22+08:00
+collections:
+ - Tests
+categories:
+ - Markdown
+tags:
+ - Code Tabs
+---
+
+Code tabs test cases for grouped code blocks.
+
+
+
+## Default Name
+
+```python {group=tab2}
+print("Python")
+```
+
+```go {group=tab2}
+fmt.Println("Go")
+```
+
+## Custom Name
+
+```js {group=tab-actions name="[JavaScript]"}
+function sum(a, b) {
+ return a + b
+}
+
+console.log(sum(1, 2))
+```
+
+```python {group=tab-actions name="[Python]"}
+def sum(a, b):
+ return a + b
+
+print(sum(1, 2))
+```
+
+## Mode
+
+```ts {group=tab-mode name="Classic"}
+interface User {
+ id: number
+ name: string
+}
+```
+
+```ts {group=tab-mode name="Mac" mode="mac"}
+interface User {
+ id: number
+ name: string
+}
+```
+
+```ts {group=tab-mode name="Simple" mode="simple"}
+interface User {
+ id: number
+ name: string
+}
+```
+
+## Actions
+
+```bash {group=tab-actions name="lineNos=true" lineNos=true}
+echo "line 1"
+echo "line 2"
+echo "line 3"
+```
+
+```bash {group=tab-actions name="lineNos=false" lineNos=false}
+echo "line 1"
+echo "line 2"
+echo "line 3"
+```
+
+```bash {group=tab-actions name="wrapping" .line-wrapping}
+printf "this is a very very very very very very very very very very very very very very very very long line"
+```
+
+```js {group=tab-actions name="editable" editable=true}
+function greet(name) {
+ return `Hello, ${name}!`
+}
+```
+
+## Expanded/Collapsed
+
+```js {group=tab-expand name="Expanded"}
+function longFunction() {
+ console.log('This is a long function.')
+ console.log('It has many lines of code.')
+ console.log('The code block should be expanded by default.')
+ console.log('Lorem ipsum dolor sit amet, consectetur adipiscing elit.')
+ console.log('Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.')
+ console.log('Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.')
+ console.log('Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.')
+ console.log('Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.')
+ console.log('The end of the long function.')
+}
+```
+
+```js {group=tab-expand name="Collapsed" .is-collapsed}
+function shortFunction() {
+ console.log('This is a short function.')
+}
+```
diff --git a/assets/css/_page/_single/_code.scss b/assets/css/_page/_single/_code.scss
index 678cab08..0cf9b10f 100644
--- a/assets/css/_page/_single/_code.scss
+++ b/assets/css/_page/_single/_code.scss
@@ -359,7 +359,7 @@ pre {
display: inline;
}
- .code-header [role='button']:not(.ellipses-btn),
+ .code-header .action-btn,
.code-expand-btn {
display: none;
}
@@ -559,3 +559,105 @@ pre {
@import './_code-syntax/github-dark-dimmed';
}
}
+
+// Code Tabs
+.code-tabs {
+ margin: 0.5rem 0;
+ background-color: fixit-var(code-block-background-color);
+ border: 1px solid fixit-var(global-border-color);
+ @include border-radius;
+
+ &:has(.code-block.active .code-expand-btn) {
+ margin-bottom: 1rem;
+ }
+
+ &:has(.code-block.active.line-wrapping) .tabs-actions .line-wrap-btn,
+ &:has(.code-block.active:not(.line-nos-hidden)) .tabs-actions .line-nos-btn,
+ &:has(.code-block.active [contenteditable='true']) .tabs-actions .edit-btn {
+ color: fixit-var(global-link-hover-color);
+ }
+
+ .tabs-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ line-height: 1.4em;
+ font-size: fixit-var(code-block-font-size);
+ color: fixit-var(code-header-color);
+ background-color: fixit-var(code-header-background-color);
+ @include border-radius(top);
+
+ .tabs-items {
+ display: flex;
+ overflow-x: auto;
+ @include border-radius(top);
+
+ .tab-item {
+ padding: 0.4rem 0.8rem;
+ cursor: pointer;
+ font-weight: bold;
+ color: fixit-var(secondary);
+ white-space: nowrap;
+ transition: all 0.2s;
+ user-select: none;
+
+ &:hover {
+ color: fixit-var(global-font-color);
+ background-color: rgba(128, 128, 128, 0.05);
+ }
+
+ &.active {
+ color: fixit-var(global-font-color);
+ box-shadow: inset 0 -2px 0 fixit-var(code-tab-active-border-color, fixit-var(primary));
+ background-color: fixit-var(code-block-background-color);
+ }
+ }
+ }
+
+ .tabs-actions {
+ display: flex;
+ flex-shrink: 0;
+
+ .action-btn {
+ padding: 0.4rem;
+
+ &:hover {
+ color: fixit-var(global-link-hover-color);
+ }
+
+ &.copy-btn[data-copied] .fa-clone {
+ --fa: "\f00c";
+ font-weight: 900;
+ color: fixit-var(global-link-hover-color);
+ }
+
+ &.download-btn[data-downloaded] .fa-download {
+ --fa: "\f1ce";
+ font-weight: 900;
+ color: fixit-var(global-link-hover-color);
+ }
+
+ &.fullscreen-btn {
+ display: none;
+ }
+ }
+ }
+ }
+
+ .tabs-content {
+ .code-block {
+ display: none;
+ margin: 0;
+ @include border-radius(bottom);
+
+ &.active {
+ display: block;
+ }
+
+ // hide original header
+ .code-header {
+ display: none;
+ }
+ }
+ }
+}
diff --git a/assets/js/theme.js b/assets/js/theme.js
index a8b22803..46c68ceb 100644
--- a/assets/js/theme.js
+++ b/assets/js/theme.js
@@ -594,6 +594,104 @@ class FixIt {
});
}
+ /**
+ * init code tabs
+ */
+ initCodeTabs() {
+ const $codeBlocks = document.querySelectorAll('.code-block[data-tab-group]');
+ const processed = new Set();
+
+ Util.forEach($codeBlocks, ($block) => {
+ if (processed.has($block)) return;
+
+ const groupName = $block.dataset.tabGroup;
+ const $tabs = [];
+ let $curr = $block;
+
+ // collect consecutive blocks with same group
+ while ($curr && $curr.classList?.contains('code-block') && $curr.dataset.tabGroup === groupName) {
+ $tabs.push($curr);
+ processed.add($curr);
+ $curr = $curr.nextElementSibling;
+ }
+
+ if ($tabs.length < 2) return;
+
+ // create DOM structure
+ const $container = document.createElement('div');
+ $container.className = 'code-tabs';
+
+ const $header = document.createElement('div');
+ $header.className = 'tabs-header';
+
+ const $items = document.createElement('div');
+ $items.className = 'tabs-items';
+
+ const $actions = document.createElement('div');
+ $actions.className = 'tabs-actions';
+
+ $header.appendChild($items);
+ $header.appendChild($actions);
+
+ const $content = document.createElement('div');
+ $content.className = 'tabs-content';
+
+ // insert container before the first block
+ const $firstBlock = $tabs[0];
+ $firstBlock.parentNode.insertBefore($container, $firstBlock);
+
+ $tabs.forEach(($tab, index) => {
+ const title = $tab.dataset.tabTitle || 'Code';
+
+ // tab button
+ const $btn = document.createElement('span');
+ $btn.className = 'tab-item';
+ if (index === 0) $btn.classList.add('active');
+ $btn.textContent = title;
+ $btn.dataset.index = index;
+ $btn.title = title;
+
+ $btn.addEventListener('click', () => {
+ // 1. restore buttons to the currently active tab
+ const $activeTab = $tabs.find(t => t.classList.contains('active'));
+ if ($activeTab) {
+ const $activeHeader = $activeTab.querySelector('.code-header');
+ if ($activeHeader) {
+ Array.from($actions.children).forEach(btn => $activeHeader.appendChild(btn));
+ }
+ }
+
+ // 2. switch active tab UI
+ $items.querySelectorAll('.tab-item').forEach(b => b.classList.remove('active'));
+ $btn.classList.add('active');
+
+ // 3. switch content
+ $tabs.forEach(b => b.classList.remove('active'));
+ $tab.classList.add('active');
+
+ // 4. move new buttons to actions
+ const $codeHeader = $tab.querySelector('.code-header');
+ if ($codeHeader) {
+ $codeHeader.querySelectorAll('.action-btn').forEach(btn => $actions.appendChild(btn));
+ }
+ });
+ $items.appendChild($btn);
+
+ // move block to content
+ $tab.classList.toggle('active', index === 0);
+ // remove is-collapsed class
+ $tab.classList.remove('is-collapsed');
+ $content.appendChild($tab);
+ });
+
+ $container.appendChild($header);
+ $container.appendChild($content);
+
+ // initialize actions for first tab
+ $items.firstElementChild.click();
+ });
+ }
+
/**
* init diagram copy button
*/
@@ -1261,6 +1359,7 @@ class FixIt {
this.initDetails(target);
this.initLightGallery();
this.initCodeWrapper();
+ this.initCodeTabs();
this.initDiagramCopyBtn();
this.initEcharts();
this.initTypeit(target);
diff --git a/layouts/_partials/function/code-header.html b/layouts/_partials/function/code-header.html
index c546d426..9bf3f6ce 100644
--- a/layouts/_partials/function/code-header.html
+++ b/layouts/_partials/function/code-header.html
@@ -18,27 +18,27 @@
{{- end -}}
{{- if $config.linenostoggler -}}
{{- $lineNosIcon := dict "Class" "fa-solid fa-list-ol" | partial "plugin/icon.html" -}}
- {{- $lineNosBtn = printf `%s` (T "assets.toggleLineNumbers") (T "assets.toggleLineNumbers") $lineNosIcon -}}
+ {{- $lineNosBtn = printf `%s` (T "assets.toggleLineNumbers") (T "assets.toggleLineNumbers") $lineNosIcon -}}
{{- end -}}
{{- if $config.linewraptoggler -}}
{{- $lineWrapIcon := dict "Class" "fa-solid fa-right-left" | partial "plugin/icon.html" -}}
- {{- $lineWrapBtn = printf `%s` (T "assets.toggleLineWrap") (T "assets.toggleLineWrap") $lineWrapIcon -}}
+ {{- $lineWrapBtn = printf `%s` (T "assets.toggleLineWrap") (T "assets.toggleLineWrap") $lineWrapIcon -}}
{{- end -}}
{{- if $config.editable -}}
{{- $editIcon := dict "Class" "fa-solid fa-pen-to-square" | partial "plugin/icon.html" -}}
- {{- $editBtn = printf `%s` (T "assets.toggleCodeEditable") (T "assets.toggleCodeEditable") $editIcon -}}
+ {{- $editBtn = printf `%s` (T "assets.toggleCodeEditable") (T "assets.toggleCodeEditable") $editIcon -}}
{{- end -}}
{{- if $config.copyable -}}
{{- $copyIcon := dict "Class" "fa-regular fa-clone" | partial "plugin/icon.html" -}}
- {{- $copyBtn = printf `%s` (T "assets.copyToClipboard") (T "assets.copyToClipboard") $copyIcon -}}
+ {{- $copyBtn = printf `%s` (T "assets.copyToClipboard") (T "assets.copyToClipboard") $copyIcon -}}
{{- end -}}
{{- if $config.downloadable -}}
{{- $downloadIcon := dict "Class" "fa-solid fa-download" | partial "plugin/icon.html" -}}
- {{- $downloadBtn = printf `%s` (T "assets.downloadCode") (T "assets.downloadCode") $downloadIcon -}}
+ {{- $downloadBtn = printf `%s` (T "assets.downloadCode") (T "assets.downloadCode") $downloadIcon -}}
{{- end -}}
{{- if $config.fullscreen -}}
{{- $fullscreenIcon := dict "Class" "fa-solid fa-expand" | partial "plugin/icon.html" -}}
- {{- $fullscreenBtn = printf `%s` (T "assets.toggleCodeFullscreen") (T "assets.toggleCodeFullscreen") $fullscreenIcon -}}
+ {{- $fullscreenBtn = printf `%s` (T "assets.toggleCodeFullscreen") (T "assets.toggleCodeFullscreen") $fullscreenIcon -}}
{{- end -}}
{{- with $config.name -}}
{{- $titleEl = replace $titleEl `` (printf `` .) -}}
diff --git a/layouts/_partials/plugin/code-block-wrapper.html b/layouts/_partials/plugin/code-block-wrapper.html
index 06c5159c..dc3aa35b 100644
--- a/layouts/_partials/plugin/code-block-wrapper.html
+++ b/layouts/_partials/plugin/code-block-wrapper.html
@@ -80,6 +80,10 @@
$lineNosDigit
)
-}}
+ {{- if $config.group -}}
+ {{- $tabTitle := $config.name | default (strings.FirstUpper .Type) | default "Code" -}}
+ {{- $wrapper = replace $wrapper (printf `class="%s"` $class) (printf `class="%s" data-tab-group="%s" data-tab-title="%s"` $class $config.group $tabTitle) -}}
+ {{- end -}}
{{- $wrapper =
replace $wrapper
(printf `class="%s"` $class)