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="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="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>
Value State
The value
represents the currently selected tab within the Tabs
component.
Bits UI provides flexible options for controlling and synchronizing the Tabs
component's value state.
Two-Way Binding
Use the bind:value
directive for effortless two-way synchronization between your local state and the Tabs
component's value.
<script lang="ts">
import { Tabs } from "bits-ui";
let value = $state("");
</script>
<button onclick={() => (value = "A")}> Set to A </button>
<Tabs.Root bind:value>
<!-- ... -->
</Tabs.Root>
This setup enables changing the Tabs
component's value via the custom button and ensures the local value
state is synchronized with the Tabs
component's value.
Change Handler
You can also use the onValueChange
prop to update local state when the Tabs
component's value changes. This is useful when you don't want two-way binding for one reason or another, or you want to perform additional logic when the Tabs
component's value changes.
<script lang="ts">
import { Tabs } from "bits-ui";
let value = $state("");
</script>
<Tabs.Root
bind:value
onValueChange={(v) => {
value = v;
}}
>
<!-- ... -->
</Tabs.Root>
Controlled
Sometimes, you may want complete control over the component's value
state, meaning you will be "kept in the loop" and be required to apply the state change yourself. While you will rarely need this, it's possible to do so by setting the controlledValue
prop to true
.
You will then be responsible for updating a local value state variable that is passed as the value
prop to the Tabs.Root
component.
<script lang="ts">
import { Tabs } from "bits-ui";
let myValue = $state();
</script>
<Tabs.Root controlledValue value={myValue} onValueChange={(v) => (myValue = v)}>
<!-- ... -->
</Tabs.Root>
See the Controlled State documentation for more information about controlled states.
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>
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 |
controlledValue | boolean | Whether or not the value is controlled or not. If Default: false |
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 delegation 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 delegation 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 delegation 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 delegation docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-tabs-content | '' | Present on the content elements. |