Tabs
Panels of content where one shows at a time.
Melt UI Tabs
Make changes to your account here.
Change your password here.
Billing details.
<script lang="ts">
import * as Tabs from '#lib/components/ui/tabs/index.js';
</script>
<Tabs.Root value="account">
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="password">Password</Tabs.Trigger>
<Tabs.Trigger value="billing" disabled>Billing</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">Make changes to your account here.</Tabs.Content>
<Tabs.Content value="password">Change your password here.</Tabs.Content>
<Tabs.Content value="billing">Billing details.</Tabs.Content>
</Tabs.Root>
Line variant
The line variant.
Analytics content.
Reports content.
<script lang="ts">
import * as Tabs from '#lib/components/ui/tabs/index.js';
</script>
<Tabs.Root value="overview">
<Tabs.List variant="line">
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="analytics">Analytics</Tabs.Trigger>
<Tabs.Trigger value="reports">Reports</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview">The line variant.</Tabs.Content>
<Tabs.Content value="analytics">Analytics content.</Tabs.Content>
<Tabs.Content value="reports">Reports content.</Tabs.Content>
</Tabs.Root>
Installation
npx distill-ui add tabsIt installs @floating-ui/domandmelt if you don't have them yet.
- Set up the tokens once, if you haven't: see manual installation.
- Install the packages it needs:
npm install @floating-ui/dom@^1.6.0 melt@0.44.0 - Copy these files into your project:
import { getContext, setContext } from 'svelte';
import type { Tabs } from 'melt/builders';
export type TabsListVariant = 'default' | 'line';
// One Melt builder per tab set, shared by its parts. The list's variant changes how its
// triggers look, so it travels through context too, letting each trigger style itself in
// its own file.
type TabsContext = { tabs: Tabs<string>; listVariant: TabsListVariant };
const key = Symbol('distill-tabs');
export const setTabsContext = (ctx: TabsContext) => setContext(key, ctx);
export const getTabsContext = () => getContext<TabsContext>(key);
import Content from './tabs-content.svelte';
import List from './tabs-list.svelte';
import Trigger from './tabs-trigger.svelte';
import Root from './tabs.svelte';
export type { TabsListVariant } from './context.js';
export { Root, List, Trigger, Content };
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getTabsContext } from './context.js';
let {
class: className,
value,
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & { value: string } = $props();
const ctx = getTabsContext();
</script>
<div
{...restProps}
{...ctx.tabs.getContent(value)}
role="tabpanel"
tabindex="0"
class={['content', className]}
>
{@render children?.()}
</div>
<style>
.content {
flex: 1;
font-size: var(--dui-text-sm);
line-height: var(--dui-text-sm-line-height);
outline: none;
&:focus-visible {
border-radius: var(--dui-radius-md);
box-shadow: 0 0 0 var(--dui-ring-width)
color-mix(in oklch, var(--dui-color-ring) 50%, transparent);
}
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getTabsContext, type TabsListVariant } from './context.js';
let {
class: className,
variant = 'default',
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & { variant?: TabsListVariant } = $props();
const ctx = getTabsContext();
$effect.pre(() => {
ctx.listVariant = variant;
});
</script>
<div {...restProps} {...ctx.tabs.triggerList} class={['list', className]} data-variant={variant}>
{@render children?.()}
</div>
<!--
Options you can set from a parent or on the tabs list itself:
--dui-tabs-list-height, --dui-tabs-list-radius, --dui-tabs-list-bg
-->
<style>
.list {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
height: var(--dui-tabs-list-height, 2.25rem);
padding: 3px;
border-radius: var(--dui-tabs-list-radius, var(--dui-radius-lg));
background: var(--dui-tabs-list-bg, var(--dui-color-muted));
color: var(--dui-color-muted-foreground);
&[data-variant='line'] {
gap: var(--dui-space-1);
border-radius: 0;
background: var(--dui-tabs-list-bg, transparent);
}
&[data-orientation='vertical'] {
flex-direction: column;
height: fit-content;
}
}
</style>
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import { getTabsContext } from './context.js';
let {
class: className,
value,
disabled = false,
children,
...restProps
}: HTMLButtonAttributes & { value: string } = $props();
const ctx = getTabsContext();
// Melt has no disabled tabs. It finds tabs by their data-melt-tabs-trigger marker, so
// leaving the marker off a disabled tab takes it out of arrow-key navigation.
const trigger = $derived.by(() => {
const props: Record<string, unknown> = { ...ctx.tabs.getTrigger(value) };
if (disabled) delete props['data-melt-tabs-trigger'];
return props;
});
</script>
<button
type="button"
{...restProps}
{...trigger}
{disabled}
class={['trigger', className]}
data-list-variant={ctx.listVariant}
>
{@render children?.()}
</button>
<!--
Options you can set from a parent or on the tabs trigger itself:
--dui-tabs-trigger-active-bg
-->
<style>
.trigger {
position: relative;
display: inline-flex;
flex: 1;
align-items: center;
justify-content: center;
gap: var(--dui-space-1-5);
height: calc(100% - 1px);
padding: var(--dui-space-1) var(--dui-space-2);
border: 1px solid transparent;
border-radius: var(--dui-radius-md);
background: transparent;
color: color-mix(in oklch, var(--dui-color-foreground) 60%, transparent);
font: inherit;
font-size: var(--dui-text-sm);
line-height: var(--dui-text-sm-line-height);
font-weight: var(--dui-font-weight-medium);
white-space: nowrap;
cursor: pointer;
transition:
color var(--dui-duration-fast) var(--dui-ease-out),
background-color var(--dui-duration-fast) var(--dui-ease-out),
box-shadow var(--dui-duration-fast) var(--dui-ease-out);
&:hover {
color: var(--dui-color-foreground);
}
&:focus-visible {
outline: 1px solid var(--dui-color-ring);
border-color: var(--dui-color-ring);
box-shadow: 0 0 0 var(--dui-ring-width)
color-mix(in oklch, var(--dui-color-ring) 50%, transparent);
}
&:disabled {
pointer-events: none;
opacity: 0.5;
}
&[data-orientation='vertical'] {
width: 100%;
justify-content: flex-start;
}
/* Underline for the "line" variant, hidden until active. */
&::after {
content: '';
position: absolute;
background: var(--dui-color-foreground);
opacity: 0;
transition: opacity var(--dui-duration-fast) var(--dui-ease-out);
}
&[data-orientation='horizontal']::after {
inset-inline: 0;
bottom: -5px;
height: 2px;
}
&[data-orientation='vertical']::after {
inset-block: 0;
right: -4px;
width: 2px;
}
}
.trigger[data-active][data-list-variant='default'] {
border-color: var(--dui-color-tab-active-border);
background: var(--dui-tabs-trigger-active-bg, var(--dui-color-tab-active));
color: var(--dui-color-foreground);
box-shadow: var(--dui-shadow-sm);
}
.trigger[data-active][data-list-variant='line'] {
color: var(--dui-color-foreground);
&::after {
opacity: 1;
}
}
</style>
<script lang="ts">
import { Tabs } from 'melt/builders';
import type { HTMLAttributes } from 'svelte/elements';
import { setTabsContext } from './context.js';
let {
value = $bindable(''),
orientation = 'horizontal',
class: className,
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
value?: string;
orientation?: 'horizontal' | 'vertical';
} = $props();
const tabs = new Tabs<string>({
value: () => value,
onValueChange: (v) => (value = v),
orientation: () => orientation,
loop: true
});
const ctx = $state({ tabs, listVariant: 'default' as const });
setTabsContext(ctx);
</script>
<div {...restProps} class={['tabs', className]} data-orientation={orientation}>
{@render children?.()}
</div>
<style>
.tabs {
display: flex;
flex-direction: column;
gap: var(--dui-space-2);
&[data-orientation='vertical'] {
flex-direction: row;
}
}
</style>
Customizing
Set any of these on the component or on a parent element. See Customizing for how they work.
| Custom property | Default |
|---|---|
--dui-tabs-list-height | 2.25rem |
--dui-tabs-list-radius | var(--dui-radius-lg) |
--dui-tabs-list-bg | var(--dui-color-muted) |
--dui-tabs-trigger-active-bg | var(--dui-color-tab-active) |