Tabs
Organizes content into distinct sections, allowing users to switch between them.
Prague
06:05
3h 30m
Malaga
06:05
Malaga
07:25
3h 20m
Prague
10:45
<script lang="ts">
import { Tabs } from "bits-ui";
import Airplane from "phosphor-svelte/lib/Airplane";
</script>
<div class="pt-6">
<Tabs.Root
value="outbound"
class="w-[390px] rounded-card border border-muted bg-background-alt p-3 shadow-card"
>
<Tabs.List
class="grid w-full grid-cols-2 gap-1 rounded-9px bg-dark-10 p-1 text-sm font-semibold leading-[0.01em] shadow-mini-inset dark:border dark:border-neutral-600/30 dark:bg-background"
>
<Tabs.Trigger
value="outbound"
class="h-8 rounded-[7px] bg-transparent py-2 data-[state=active]:bg-white data-[state=active]:shadow-mini dark:data-[state=active]:bg-muted"
>Outbound</Tabs.Trigger
>
<Tabs.Trigger
value="inbound"
class="h-8 rounded-[7px] bg-transparent py-2 data-[state=active]:bg-white data-[state=active]:shadow-mini dark:data-[state=active]:bg-muted"
>Inbound</Tabs.Trigger
>
</Tabs.List>
<Tabs.Content value="outbound" class="select-none pt-3">
<div class="grid grid-cols-3 grid-rows-2 gap-0 p-4 pb-1">
<div class="text-left">
<h4
class="mb-2 text-[20px] font-semibold leading-none tracking-[-0.01em]"
>
Prague
</h4>
<p class="text-sm font-medium text-muted-foreground">06:05</p>
</div>
<div class="self-end text-center">
<p class="text-sm font-medium text-muted-foreground">3h 30m</p>
</div>
<div class="text-right">
<h4
class="mb-2 text-[20px] font-semibold leading-none tracking-[-0.01em]"
>
Malaga
</h4>
<p class="text-sm font-medium text-muted-foreground">06:05</p>
</div>
<div class="relative col-span-3">
<hr
class="border-1 relative top-4 h-px border-dashed border-border-input"
/>
<div class="absolute left-1/2 -translate-x-1/2 bg-background-alt p-1">
<Airplane class="size-6 rotate-90 text-muted-foreground" />
</div>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="inbound" class="select-none pt-3">
<div class="grid grid-cols-3 grid-rows-2 gap-0 p-4 pb-1">
<div class="text-left">
<h4
class="mb-2 text-[20px] font-semibold leading-none tracking-[-0.01em]"
>
Malaga
</h4>
<p class="text-sm font-medium text-muted-foreground">07:25</p>
</div>
<div class="self-end text-center">
<p class="text-sm font-medium text-muted-foreground">3h 20m</p>
</div>
<div class="text-right">
<h4
class="mb-2 text-[20px] font-semibold leading-none tracking-[-0.01em]"
>
Prague
</h4>
<p class="text-sm font-medium text-muted-foreground">10:45</p>
</div>
<div class="relative col-span-3">
<hr
class="border-1 relative top-4 h-px border-dashed border-border-input"
/>
<div class="absolute left-1/2 -translate-x-1/2 bg-background-alt p-1">
<Airplane class="size-6 rotate-90 text-muted-foreground" />
</div>
</div>
</div>
</Tabs.Content>
</Tabs.Root>
</div>
import typography from "@tailwindcss/typography";
import animate from "tailwindcss-animate";
import { fontFamily } from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
container: {
center: true,
screens: {
"2xl": "1440px",
},
},
extend: {
colors: {
border: {
DEFAULT: "hsl(var(--border-card))",
input: "hsl(var(--border-input))",
"input-hover": "hsl(var(--border-input-hover))",
},
background: {
DEFAULT: "hsl(var(--background) / <alpha-value>)",
alt: "hsl(var(--background-alt) / <alpha-value>)",
},
foreground: {
DEFAULT: "hsl(var(--foreground) / <alpha-value>)",
alt: "hsl(var(--foreground-alt) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground))",
},
dark: {
DEFAULT: "hsl(var(--dark) / <alpha-value>)",
4: "hsl(var(--dark-04))",
10: "hsl(var(--dark-10))",
40: "hsl(var(--dark-40))",
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
},
contrast: {
DEFAULT: "hsl(var(--contrast) / <alpha-value>)",
},
},
fontFamily: {
sans: ["Inter", ...fontFamily.sans],
mono: ["Source Code Pro", ...fontFamily.mono],
alt: ["Courier", ...fontFamily.sans],
},
fontSize: {
xxs: "10px",
},
borderWidth: {
6: "6px",
},
borderRadius: {
card: "16px",
"card-lg": "20px",
"card-sm": "10px",
input: "9px",
button: "5px",
"5px": "5px",
"9px": "9px",
"10px": "10px",
"15px": "15px",
},
height: {
input: "3rem",
"input-sm": "2.5rem",
},
boxShadow: {
mini: "var(--shadow-mini)",
"mini-inset": "var(--shadow-mini-inset)",
popover: "var(--shadow-popover)",
kbd: "var(--shadow-kbd)",
btn: "var(--shadow-btn)",
card: "var(--shadow-card)",
"date-field-focus": "var(--shadow-date-field-focus)",
},
opacity: {
8: "0.08",
},
scale: {
80: ".80",
98: ".98",
99: ".99",
},
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--bits-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--bits-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
},
},
plugins: [typography, animate],
};
@import url("https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Colors */
--background: 0 0% 100%;
--background-alt: 0 0% 100%;
--foreground: 0 0% 9%;
--foreground-alt: 0 0% 32%;
--muted: 240 5% 96%;
--muted-foreground: 0 0% 9% / 0.4;
--border: 240 6% 10%;
--border-input: 240 6% 10% / 0.17;
--border-input-hover: 240 6% 10% / 0.4;
--border-card: 240 6% 10% / 0.1;
--dark: 240 6% 10%;
--dark-10: 240 6% 10% / 0.1;
--dark-40: 240 6% 10% / 0.4;
--dark-04: 240 6% 10% / 0.04;
--accent: 204 94% 94%;
--accent-foreground: 204 80% 16%;
--destructive: 347 77% 50%;
/* black */
--constrast: 0 0% 0%;
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.04) inset;
--shadow-popover: 0px 7px 12px 3px hsla(var(--dark-10));
--shadow-kbd: 0px 2px 0px 0px rgba(0, 0, 0, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.03);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(24, 24, 27, 0.17);
}
.dark {
/* Colors */
--background: 0 0% 5%;
--background-alt: 0 0% 8%;
--foreground: 0 0% 95%;
--foreground-alt: 0 0% 70%;
--muted: 240 4% 16%;
--muted-foreground: 0 0% 100% / 0.4;
--border: 0 0% 96%;
--border-input: 0 0% 96% / 0.17;
--border-input-hover: 0 0% 96% / 0.4;
--border-card: 0 0% 96% / 0.1;
--dark: 0 0% 96%;
--dark-40: 0 0% 96% / 0.4;
--dark-10: 0 0% 96% / 0.1;
--dark-04: 0 0% 96% / 0.04;
--accent: 204 90 90%;
--accent-foreground: 204 94% 94%;
--destructive: 350 89% 60%;
/* white */
--constrast: 0 0% 100%;
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.3);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.5) inset;
--shadow-popover: 0px 7px 12px 3px hsla(0deg 0% 0% / 30%);
--shadow-kbd: 0px 2px 0px 0px rgba(255, 255, 255, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.2);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.4);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(244, 244, 245, 0.1);
}
}
@layer base {
* {
@apply border-border;
}
html {
-webkit-text-size-adjust: 100%;
font-variation-settings: normal;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
/* Mobile tap highlight */
/* https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color */
html {
-webkit-tap-highlight-color: rgba(128, 128, 128, 0.5);
}
::selection {
background: #fdffa4;
color: black;
}
/* === Scrollbars === */
::-webkit-scrollbar {
@apply w-2;
@apply h-2;
}
::-webkit-scrollbar-track {
@apply !bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply rounded-card-lg !bg-dark-10;
}
::-webkit-scrollbar-corner {
background: rgba(0, 0, 0, 0);
}
/* Firefox */
/* https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color#browser_compatibility */
html {
scrollbar-color: var(--bg-muted);
}
.antialised {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer utilities {
.step {
counter-increment: step;
}
.step:before {
@apply absolute inline-flex h-9 w-9 items-center justify-center rounded-full border-4 border-background bg-muted text-center -indent-px font-mono text-base font-medium;
@apply ml-[-50px] mt-[-4px];
content: counter(step);
}
}
@layer components {
*:not(body):not(.focus-override) {
outline: none !important;
&:focus-visible {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
}
.link {
@apply inline-flex items-center gap-1 rounded-sm font-medium underline underline-offset-4 hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
}
}
Structure
<script lang="ts">
import { Tabs } from "bits-ui";
</script>
<Tabs.Root>
<Tabs.List>
<Tabs.Trigger />
</Tabs.List>
<Tabs.Content />
</Tabs.Root>
Managing Value State
Bits UI offers several approaches to manage and synchronize the component's value state, catering to different levels of control and integration needs.
1. Two-Way Binding
For seamless state synchronization, use Svelte's bind:value
directive. This method automatically keeps your local state in sync with the component's internal state.
<script lang="ts">
import { Tabs } from "bits-ui";
let myValue = $state("");
</script>
<button onclick={() => (myValue = "tab-1")}> Activate tab 1 </button>
<Tabs.Root bind:value={myValue}>
<!-- -->
</Tabs.Root>
Key Benefits
- Simplifies state management
- Automatically updates
myValue
when the internal state changes (e.g., via clicking on an tab's trigger) - Allows external control (e.g., switching tabs via a separate button)
2. Change Handler
For more granular control or to perform additional logic on state changes, use the onValueChange
prop. This approach is useful when you need to execute custom logic alongside state updates.
<script lang="ts">
import { Tabs } from "bits-ui";
let myValue = $state("");
</script>
<Tabs.Root
value={myValue}
onValueChange={(v) => {
myValue = v;
// additional logic here.
}}
>
<!-- ... -->
</Tabs.Root>
Use Cases
- Implementing custom behaviors on value change
- Integrating with external state management solutions
- Triggering side effects (e.g., logging, data fetching)
3. Fully Controlled
For complete control over the component's state, use a Function Binding to manage the value state externally.
<script lang="ts">
import { Tabs } from "bits-ui";
let myValue = $state("");
</script>
<Tabs.Root bind:value={() => myValue, (newValue) => (myValue = newValue)}>
<!-- ... -->
</Tabs.Root>
When to Use
- Implementing complex logic
- Coordinating multiple UI elements
- Debugging state-related issues
Note
While powerful, fully controlled state should be used judiciously as it increases complexity and can cause unexpected behaviors if not handled carefully.
For more in-depth information on controlled components and advanced state management techniques, refer to our Controlled State documentation.
Orientation
The orientation
prop is used to determine the orientation of the Tabs
component, which influences how keyboard navigation will work.
When the orientation
is set to 'horizontal'
, the ArrowLeft
and ArrowRight
keys will move the focus to the previous and next tab, respectively. When the orientation
is set to 'vertical'
, the ArrowUp
and ArrowDown
keys will move the focus to the previous and next tab, respectively.
<Tabs.Root orientation="horizontal">
<!-- ... -->
</Tabs.Root>
<Tabs.Root orientation="vertical">
<!-- ... -->
</Tabs.Root>
Activation Mode
By default, the Tabs
component will automatically activate the tab associated with a trigger when that trigger is focused. This behavior can be disabled by setting the activationMode
prop to 'manual'
.
When set to 'manual'
, the user will need to activate the tab by pressing the trigger.
<Tabs.Root activationMode="manual">
<!-- ... -->
</Tabs.Root>
API Reference
The root tabs component which contains the other tab components.
Property | Type | Description |
---|---|---|
value $bindable | string | The active tab value. Default: undefined |
onValueChange | function | A callback function called when the active tab value changes. Default: undefined |
activationMode | enum | How the activation of tabs should be handled. If set to Default: 'automatic' |
disabled | boolean | Whether or not the tabs are disabled. Default: false |
loop | boolean | Whether or not the tabs should loop when navigating with the keyboard. Default: true |
orientation | enum | The orientation of the tabs. Default: horizontal |
ref $bindable | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See Child Snippet docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-orientation | enum | The orientation of the tabs. |
data-tabs-root | '' | Present on the root element. |
The component containing the tab triggers.
Property | Type | Description |
---|---|---|
ref $bindable | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See Child Snippet docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-orientation | enum | The orientation of the tabs. |
data-tabs-list | '' | Present on the list element. |
The trigger for a tab.
Property | Type | Description |
---|---|---|
value required | string | The value of the tab this trigger represents. Default: undefined |
disabled | boolean | Whether or not the tab is disabled. Default: false |
ref $bindable | HTMLButtonElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See Child Snippet docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-state | enum | The state of the tab trigger. |
data-value | '' | The value of the tab this trigger represents. |
data-orientation | enum | The orientation of the tabs. |
data-disabled | '' | Present when the tab trigger is disabled. |
data-tabs-trigger | '' | Present on the trigger elements. |
The panel containing the contents of a tab.
Property | Type | Description |
---|---|---|
value required | string | The value of the tab this content represents. Default: undefined |
ref $bindable | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See Child Snippet docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-tabs-content | '' | Present on the content elements. |