Tabs

tabs.svelte

A tab switcher and bufferline navigation with arrow key support.

Demo

LINE (DEFAULT / BUFFERLINE):
BLOCK:
BRACKET:
Usage
001 <script lang="ts">
002 import Tabs from '$lib/components/tabs.svelte';
003
004 let active1 = $state('editor');
005 let active2 = $state('logs');
006 let active3 = $state('preview');
007
008 const items = [
009 { value: 'editor', label: 'input.svelte', count: 3 },
010 { value: 'logs', label: 'build.log' },
011 { value: 'preview', label: 'Preview' },
012 { value: 'disabled', label: 'Archived', disabled: true }
013 ];
014 </script>
015
016 <div class="space-y-6">
017 <div>
018 <div class="text-xs opacity-50">LINE (DEFAULT / BUFFERLINE):</div>
019 <Tabs {items} bind:value={active1} variant="line" />
020 </div>
021
022 <div>
023 <div class="text-xs opacity-50">BLOCK:</div>
024 <Tabs {items} bind:value={active2} variant="block" />
025 </div>
026
027 <div>
028 <div class="text-xs opacity-50">BRACKET:</div>
029 <Tabs {items} bind:value={active3} variant="bracket" />
030 </div>
031 </div>
032
tabs.svelte
001 <script lang="ts" module>
002 import type { HTMLAttributes } from 'svelte/elements';
003
004 export type TabItem =
005 | string
006 | {
007 value: string;
008 label?: string;
009 count?: number | string;
010 disabled?: boolean;
011 };
012
013 export type TabsProps = Omit<HTMLAttributes<HTMLElement>, 'onchange'> & {
014 ref?: HTMLElement | null;
015 items?: TabItem[];
016 value?: string;
017 variant?: 'line' | 'block' | 'bracket';
018 onchange?: (value: string) => void;
019 };
020 </script>
021
022 <script lang="ts">
023 let {
024 ref = $bindable(null),
025 items = [],
026 value = $bindable(''),
027 variant = 'line',
028 class: className = '',
029 onchange,
030 ...restProps
031 }: TabsProps = $props();
032
033 const normalizedItems = $derived(
034 items.map((item) => (typeof item === 'string' ? { value: item, label: item } : item))
035 );
036
037 $effect(() => {
038 if (!value && normalizedItems.length > 0) {
039 const first = normalizedItems.find((i) => !i.disabled);
040 if (first) value = first.value;
041 }
042 });
043
044 function selectTab(val: string) {
045 value = val;
046 onchange?.(val);
047 }
048
049 function handleKeydown(e: KeyboardEvent) {
050 const enabled = normalizedItems.filter((i) => !i.disabled);
051 if (enabled.length === 0) return;
052
053 const currentIdx = enabled.findIndex((i) => i.value === value);
054
055 if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
056 e.preventDefault();
057 const nextIdx = (currentIdx + 1) % enabled.length;
058 selectTab(enabled[nextIdx].value);
059 } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
060 e.preventDefault();
061 const prevIdx = (currentIdx - 1 + enabled.length) % enabled.length;
062 selectTab(enabled[prevIdx].value);
063 }
064 }
065 </script>
066
067 <nav
068 bind:this={ref}
069 class="tabs-container {variant} {className}"
070 role="tablist"
071 onkeydown={handleKeydown}
072 {...restProps}
073 >
074 {#each normalizedItems as item, idx (item.value)}
075 {@const active = item.value === value}
076 <button
077 type="button"
078 role="tab"
079 aria-selected={active}
080 disabled={item.disabled}
081 tabindex={active ? 0 : -1}
082 class="tab-btn"
083 class:active
084 onclick={() => selectTab(item.value)}
085 >
086 {#if variant === 'bracket'}[{/if}
087 <span class="tab-idx">{idx + 1}</span>
088 <span class="tab-label">{item.label ?? item.value}</span>
089 {#if item.count !== undefined}
090 <span class="tab-count">({item.count})</span>
091 {/if}
092 {#if variant === 'bracket'}]{/if}
093 </button>
094 {/each}
095 </nav>
096
097 <style>
098 .tabs-container {
099 display: flex;
100 align-items: center;
101 height: var(--line);
102 line-height: var(--line);
103 font-family: var(--font-family-mono);
104 font-size: var(--font-size);
105 box-sizing: border-box;
106 user-select: none;
107 }
108
109 .tabs-container.line {
110 box-shadow: inset 0 -1px 0 0 var(--border-default);
111 }
112
113 .tab-btn {
114 display: inline-flex;
115 align-items: center;
116 gap: 0.5ch;
117 height: var(--line);
118 line-height: var(--line);
119 padding: 0 1.5ch;
120 font-family: inherit;
121 font-size: inherit;
122 background: transparent;
123 color: var(--button-muted);
124 border: 0;
125 cursor: pointer;
126 outline: 0;
127 box-sizing: border-box;
128 }
129
130 .tab-btn:disabled {
131 opacity: 0.35;
132 cursor: not-allowed;
133 }
134
135 .tab-btn:not(:disabled):hover {
136 color: var(--text-primary);
137 }
138
139 .tab-btn:focus-visible {
140 background: color-mix(in srgb, var(--focus-ring) 20%, transparent);
141 }
142
143 /* LINE VARIANT */
144 .tabs-container.line .tab-btn {
145 box-shadow: inset -1px 0 0 0 var(--border-default);
146 }
147
148 .tabs-container.line .tab-btn.active {
149 color: var(--text-primary);
150 font-weight: bold;
151 background: var(--surface-base);
152 box-shadow:
153 inset 0 2px 0 0 var(--focus-ring),
154 inset -1px 0 0 0 var(--border-default);
155 }
156
157 /* BLOCK VARIANT */
158 .tabs-container.block {
159 gap: 1ch;
160 }
161
162 .tabs-container.block .tab-btn.active {
163 background: var(--text-primary);
164 color: var(--surface-base);
165 font-weight: bold;
166 }
167
168 /* BRACKET VARIANT */
169 .tabs-container.bracket {
170 gap: 1ch;
171 }
172
173 .tabs-container.bracket .tab-btn {
174 padding: 0 0.5ch;
175 }
176
177 .tabs-container.bracket .tab-btn.active {
178 color: var(--focus-ring);
179 font-weight: bold;
180 }
181
182 .tab-idx {
183 opacity: 0.45;
184 font-size: 0.9em;
185 }
186
187 .tab-count {
188 opacity: 0.6;
189 }
190 </style>
191