Tabs
A set of layered sections of content—known as tab panels—that are displayed one at a time.
Account
Make changes to your account here. Click save when you're done.
Password
Change your password here. After saving, you'll be logged out.
import { css } from 'remix/component'
import { Button } from '#app/ui/button.tsx'
import {
Card,
CardContent,import { css } from 'remix/component'
import { Button } from '#app/ui/button.tsx'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '#app/ui/card.tsx'
import { Input } from '#app/ui/input.tsx'
import { Label } from '#app/ui/label.tsx'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '#app/ui/tabs.tsx'
const content = css({ display: 'grid', gap: '1.5rem' })
const field = css({ display: 'grid', gap: '0.75rem' })
export function TabsDemo() {
return () => (
<div
mix={css({
display: 'flex',
width: '100%',
maxWidth: '24rem',
flexDirection: 'column',
gap: '1.5rem',
})}
>
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>
Make changes to your account here. Click save when you're done.
</CardDescription>
</CardHeader>
<CardContent mix={content}>
<div mix={field}>
<Label htmlFor="tabs-demo-name">Name</Label>
<Input id="tabs-demo-name" defaultValue="Pedro Duarte" />
</div>
<div mix={field}>
<Label htmlFor="tabs-demo-username">Username</Label>
<Input id="tabs-demo-username" defaultValue="@peduarte" />
</div>
</CardContent>
<CardFooter>
<Button>Save changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
<CardDescription>
Change your password here. After saving, you'll be logged out.
</CardDescription>
</CardHeader>
<CardContent mix={content}>
<div mix={field}>
<Label htmlFor="tabs-demo-current">Current password</Label>
<Input id="tabs-demo-current" type="password" />
</div>
<div mix={field}>
<Label htmlFor="tabs-demo-new">New password</Label>
<Input id="tabs-demo-new" type="password" />
</div>
</CardContent>
<CardFooter>
<Button>Save password</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
)
}Installation
$ bunx --bun shadcn@latest add https://www.shadcn-remix.com/r/tabs.json$ npx shadcn@latest add https://www.shadcn-remix.com/r/tabs.json$ pnpm dlx shadcn@latest add https://www.shadcn-remix.com/r/tabs.json$ yarn dlx shadcn@latest add https://www.shadcn-remix.com/r/tabs.jsonInstall the following dependencies:
$ bun add remix @remix-run/ui$ npm install remix @remix-run/ui$ pnpm add remix @remix-run/ui$ yarn add remix @remix-run/uiCopy and paste the following code into your project.
import * as tabs from '@remix-run/ui/tabs'
import { css, on, type CSSMixinDescriptor, type Handle, type Props } from 'remix/component'
export type TabsOrientation = 'horizontal' | 'vertical'
export type TabsListVariant = 'default' | 'line'
export interface TabsProps extends Omit<Props<'div'>, 'defaultValue'> {
/** The controlled active tab. */
value?: string
/** The tab that is active initially when uncontrolled. */
defaultValue?: string
onValueChange?: (value: string) => void
orientation?: TabsOrientation
}
export interface TabsListProps extends Props<'div'> {
variant?: TabsListVariant
}
export interface TabsTriggerProps extends Omit<Props<'button'>, 'value'> {
value: string
}
export interface TabsContentProps extends Omit<Props<'div'>, 'value'> {
value: string
}
interface TabsContextValue {
readonly orientation: TabsOrientation
}
const tabsStyles = css({
display: 'flex',
gap: '0.5rem',
'&[data-orientation="horizontal"]': { flexDirection: 'column' },
})
const tabsListBase = css({
display: 'inline-flex',
width: 'fit-content',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'var(--radius)',
padding: '3px',
color: 'var(--muted-foreground)',
'[data-slot="tabs"][data-orientation="horizontal"] &': { height: '2.25rem' },
'[data-slot="tabs"][data-orientation="vertical"] &': {
height: 'fit-content',
flexDirection: 'column',
},
'&[data-variant="line"]': { borderRadius: 0 },
})
const tabsListVariantStyles: Record<TabsListVariant, CSSMixinDescriptor> = {
default: css({ backgroundColor: 'var(--muted)' }),
line: css({ gap: '0.25rem', backgroundColor: 'transparent' }),
}
const lineList = '[data-slot="tabs-list"][data-variant="line"]'
const tabsTriggerStyles = css({
position: 'relative',
display: 'inline-flex',
height: 'calc(100% - 1px)',
flex: '1 1 0%',
alignItems: 'center',
justifyContent: 'center',
gap: '0.375rem',
borderRadius: 'calc(var(--radius) - 2px)',
borderWidth: '1px',
borderColor: 'transparent',
paddingInline: '0.5rem',
paddingBlock: '0.25rem',
fontSize: '0.875rem',
lineHeight: 'calc(1.25 / 0.875)',
fontWeight: 500,
whiteSpace: 'nowrap',
color: 'color-mix(in oklab, var(--foreground) 60%, transparent)',
transitionProperty: 'all',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '150ms',
boxShadow: 'var(--tw-ring-shadow), var(--tw-shadow)',
'[data-slot="tabs"][data-orientation="vertical"] &': {
width: '100%',
justifyContent: 'flex-start',
},
'&:hover': { color: 'var(--foreground)' },
'&:focus-visible': {
borderColor: 'var(--ring)',
'--tw-ring-shadow':
'0 0 0 3px var(--tw-ring-color, color-mix(in oklab, var(--ring) 50%, transparent))',
outlineStyle: 'solid',
outlineWidth: '1px',
outlineColor: 'var(--ring)',
},
'&:disabled': { pointerEvents: 'none', opacity: 0.5 },
'[data-slot="tabs-list"][data-variant="default"] &[data-state="active"]': {
'--tw-shadow': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
},
'.dark &': { color: 'var(--muted-foreground)' },
'.dark &:hover': { color: 'var(--foreground)' },
'& svg': { pointerEvents: 'none', flexShrink: 0 },
'& svg:not([class*="size-"])': { width: '1rem', height: '1rem' },
[`${lineList} &`]: { backgroundColor: 'transparent' },
'&[data-state="active"]': {
backgroundColor: 'var(--background)',
color: 'var(--foreground)',
},
'.dark &[data-state="active"]': {
borderColor: 'var(--input)',
backgroundColor: 'color-mix(in oklab, var(--input) 30%, transparent)',
color: 'var(--foreground)',
},
[`${lineList} &[data-state="active"]`]: {
'--tw-shadow': '0 0 #0000',
backgroundColor: 'transparent',
},
[`.dark ${lineList} &[data-state="active"]`]: {
borderColor: 'transparent',
backgroundColor: 'transparent',
},
'&::after': {
content: '""',
position: 'absolute',
backgroundColor: 'var(--foreground)',
opacity: 0,
transitionProperty: 'opacity',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '150ms',
},
'[data-slot="tabs"][data-orientation="horizontal"] &::after': {
insetInline: 0,
bottom: '-5px',
height: '0.125rem',
},
'[data-slot="tabs"][data-orientation="vertical"] &::after': {
insetBlock: 0,
right: '-0.25rem',
width: '0.125rem',
},
[`${lineList} &[data-state="active"]::after`]: { opacity: 1 },
})
const tabsContentStyles = css({ flex: '1 1 0%', outlineStyle: 'none' })
/** The `tabsListVariants()` of shadcn/ui as a mix. */
export function tabsListVariants({ variant = 'default' }: { variant?: TabsListVariant } = {}) {
return [tabsListBase, tabsListVariantStyles[variant]]
}
export function Tabs(handle: Handle<TabsProps, TabsContextValue>) {
handle.context.set({
get orientation() {
return handle.props.orientation ?? 'horizontal'
},
})
return () => {
let {
value,
defaultValue,
onValueChange,
orientation = 'horizontal',
children,
mix,
...props
} = handle.props
return (
<tabs.Context
activeTab={value}
defaultActiveTab={defaultValue}
onActiveTabChange={onValueChange}
>
<div
data-slot="tabs"
data-orientation={orientation}
{...props}
mix={[tabsStyles, tabs.root(), mix]}
>
{children}
</div>
</tabs.Context>
)
}
}
export function TabsList(handle: Handle<TabsListProps>) {
let context = handle.context.get(Tabs)
return () => {
let { variant = 'default', mix, ...props } = handle.props
let orientation = context.orientation
return (
<div
data-slot="tabs-list"
data-variant={variant}
data-orientation={orientation}
aria-orientation={orientation}
{...props}
mix={[
tabsListVariants({ variant }),
tabs.list(),
// Radix only moves along the list's axis: horizontal tabs ignore Up/Down.
on(
'keydown',
(event) => {
let ignored =
orientation === 'horizontal'
? event.key === 'ArrowUp' || event.key === 'ArrowDown'
: event.key === 'ArrowLeft' || event.key === 'ArrowRight'
if (ignored) event.stopPropagation()
},
true,
),
mix,
]}
/>
)
}
}
export function TabsTrigger(handle: Handle<TabsTriggerProps>) {
let context = handle.context.get(Tabs)
return () => {
let { value, disabled, mix, ...props } = handle.props
return (
<button
data-slot="tabs-trigger"
data-orientation={context.orientation}
data-disabled={disabled ? '' : undefined}
disabled={disabled}
{...props}
mix={[tabsTriggerStyles, tabs.tab({ name: value, disabled }), mix]}
/>
)
}
}
export function TabsContent(handle: Handle<TabsContentProps>) {
let context = handle.context.get(Tabs)
return () => {
let { value, mix, ...props } = handle.props
return (
<div
data-slot="tabs-content"
data-orientation={context.orientation}
tabIndex={0}
{...props}
mix={[tabsContentStyles, tabs.panel({ name: value }), mix]}
/>
)
}
}Update the import paths to match your project setup.
Usage
import { Tabs, TabsContent, TabsList, TabsTrigger } from '#app/ui/tabs.tsx'<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">Make changes to your account here.</TabsContent>
<TabsContent value="password">Change your password here.</TabsContent>
</Tabs>Composition
Use the following composition to build Tabs:
Tabs
├── TabsList
│ └── TabsTrigger
└── TabsContentAPI Reference
Tabs
Root <div>. Arrow keys along the orientation, Home and End move focus and activate tabs.
| Prop | Type | Default |
|---|---|---|
value | string | - |
defaultValue | string | - |
onValueChange | (value: string) => void | - |
orientation | "horizontal" | "vertical" | "horizontal" |
mix | MixValue | - |
TabsList
The role="tablist" container.
| Prop | Type | Default |
|---|---|---|
variant | "default" | "line" | "default" |
mix | MixValue | - |
TabsTrigger
A role="tab" <button> with data-state="active" | "inactive".
| Prop | Type | Default |
|---|---|---|
value | string | - |
disabled | boolean | false |
mix | MixValue | - |
TabsContent
The role="tabpanel" for a tab; hidden while inactive.
| Prop | Type | Default |
|---|---|---|
value | string | - |
mix | MixValue | - |
tabsListVariants
Returns the tabs list styles as a mix for any element.
| Prop | Type | Default |
|---|---|---|
variant | "default" | "line" | "default" |