feat: add tab bar navigation for crowded pages

This commit is contained in:
Elias Schneider
2026-07-10 15:33:02 +02:00
parent b2711ced99
commit 28a553f63b
20 changed files with 578 additions and 332 deletions
@@ -28,7 +28,7 @@
>
<div
class="{!isAuthPage
? 'max-w-410'
? 'max-w-[1720px]'
: ''} mx-auto flex w-full items-center justify-between px-4 md:px-10"
>
<div class="flex h-16 items-center">
@@ -12,6 +12,9 @@
<TabsPrimitive.Content
bind:ref
data-slot="tabs-content"
class={cn('text-sm flex-1 outline-none', className)}
class={cn(
'text-sm flex-1 outline-none data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-200 motion-reduce:data-[state=active]:animate-none',
className
)}
{...restProps}
/>
@@ -19,22 +19,105 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { onMount } from 'svelte';
import { cn } from '$lib/utils/style.js';
let {
ref = $bindable(null),
variant = 'default',
class: className,
children,
...restProps
}: TabsPrimitive.ListProps & {
variant?: TabsListVariant;
} = $props();
let indicatorOffset = $state(0);
let indicatorSize = $state(0);
let indicatorVisible = $state(false);
let indicatorOrientation = $state<'horizontal' | 'vertical'>('horizontal');
function updateIndicator() {
if (!ref || variant !== 'line') {
indicatorVisible = false;
return;
}
const activeTrigger = ref.querySelector<HTMLElement>(
'[data-slot="tabs-trigger"][data-state="active"]'
);
if (!activeTrigger) {
indicatorVisible = false;
return;
}
const listRect = ref.getBoundingClientRect();
const triggerRect = activeTrigger.getBoundingClientRect();
indicatorOrientation = ref.dataset.orientation === 'vertical' ? 'vertical' : 'horizontal';
if (indicatorOrientation === 'vertical') {
indicatorOffset = triggerRect.top - listRect.top;
indicatorSize = triggerRect.height;
} else {
const triggerStyle = getComputedStyle(activeTrigger);
const paddingLeft = Number.parseFloat(triggerStyle.paddingLeft);
const paddingRight = Number.parseFloat(triggerStyle.paddingRight);
indicatorOffset = triggerRect.left - listRect.left + paddingLeft;
indicatorSize = triggerRect.width - paddingLeft - paddingRight;
}
indicatorVisible = true;
}
onMount(() => {
if (!ref || variant !== 'line') return;
const resizeObserver = new ResizeObserver(updateIndicator);
const observeTriggers = () => {
ref
?.querySelectorAll<HTMLElement>('[data-slot="tabs-trigger"]')
.forEach((trigger) => resizeObserver.observe(trigger));
updateIndicator();
};
const mutationObserver = new MutationObserver(observeTriggers);
resizeObserver.observe(ref);
observeTriggers();
mutationObserver.observe(ref, {
attributes: true,
attributeFilter: ['data-state'],
childList: true,
subtree: true
});
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
};
});
</script>
<TabsPrimitive.List
bind:ref
data-slot="tabs-list"
data-variant={variant}
class={cn(tabsListVariants({ variant }), className)}
class={cn(tabsListVariants({ variant }), variant === 'line' && 'relative', className)}
{...restProps}
/>
>
{@render children?.()}
{#if variant === 'line'}
<span
aria-hidden="true"
data-slot="tabs-indicator"
class={cn(
'bg-foreground pointer-events-none absolute rounded-full opacity-0 transition-[transform,width,height,opacity] duration-300 ease-out motion-reduce:transition-none',
indicatorOrientation === 'horizontal' ? 'bottom-0 left-0 h-0.5' : 'top-0 right-0 w-0.5',
indicatorVisible && 'opacity-100'
)}
style:width={indicatorOrientation === 'horizontal' ? `${indicatorSize}px` : undefined}
style:height={indicatorOrientation === 'vertical' ? `${indicatorSize}px` : undefined}
style:transform={indicatorOrientation === 'horizontal'
? `translate3d(${indicatorOffset}px, 0, 0)`
: `translate3d(0, ${indicatorOffset}px, 0)`}
></span>
{/if}
</TabsPrimitive.List>
@@ -16,7 +16,6 @@
"gap-2 rounded-full border border-transparent! px-3 py-1 text-sm font-medium group-data-vertical/tabs:rounded-2xl group-data-vertical/tabs:px-3 group-data-vertical/tabs:py-1.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground',
'after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
className
)}
{...restProps}
@@ -1,18 +1,36 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { page } from '$app/state';
import { cn } from '$lib/utils/style.js';
import { Tabs as TabsPrimitive } from 'bits-ui';
import { onMount } from 'svelte';
let {
ref = $bindable(null),
value = $bindable(''),
useHash = false,
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
}: TabsPrimitive.RootProps & {
useHash?: boolean;
} = $props();
onMount(() => {
if (useHash && page.url.hash) {
value = page.url.hash.substring(1);
}
});
function onTabChange(newValue: string) {
if (useHash && page.url.hash !== newValue) {
window.location.hash = newValue;
}
}
</script>
<TabsPrimitive.Root
bind:ref
bind:value
onValueChange={onTabChange}
data-slot="tabs"
class={cn('gap-2 group/tabs flex data-[orientation=horizontal]:flex-col', className)}
{...restProps}