Tabs
A pill-style tab list that switches the active tab on click.
A pill tab group. The panel is only rendered for the active tab, so a tab whose content is expensive does not cost anything until it is opened.
newt-tabsimport { Tabs, TabsTrigger } from "@/registry/bases/newt/ui/tabs"
export default function TabsDemo() {
return (
<Tabs defaultValue="one">
<TabsTrigger value="one">Tab one</TabsTrigger>
<TabsTrigger value="two">Tab two</TabsTrigger>
<TabsTrigger value="three">Tab three</TabsTrigger>
</Tabs>
)
}<script setup lang="ts">
import { Tabs, TabsTrigger } from "~~/registry/bases/newt/ui/tabs"
</script>
<template>
<Tabs default-value="one">
<TabsTrigger value="one">Tab one</TabsTrigger>
<TabsTrigger value="two">Tab two</TabsTrigger>
<TabsTrigger value="three">Tab three</TabsTrigger>
</Tabs>
</template>Installation
pnpm dlx newtui@latest add tabsnpx newtui@latest add tabsyarn dlx newtui@latest add tabsbunx newtui@latest add tabsCopy the source into your project
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
type TabsContextValue = {
value: string | undefined
setValue: (value: string) => void
}
const TabsContext = React.createContext<TabsContextValue | null>(null)
function useTabsContext(component: string): TabsContextValue {
const ctx = React.useContext(TabsContext)
if (!ctx) {
throw new Error(`<${component}> must be used within <Tabs>`)
}
return ctx
}
export interface TabsProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"defaultValue"
> {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
}
const Tabs = React.forwardRef<HTMLDivElement, TabsProps>(
(
{ className, value, defaultValue, onValueChange, children, ...props },
ref
) => {
const [internal, setInternal] = React.useState<string | undefined>(
defaultValue
)
const isControlled = value !== undefined
const current = isControlled ? value : internal
const setValue = React.useCallback(
(next: string) => {
if (!isControlled) setInternal(next)
onValueChange?.(next)
},
[isControlled, onValueChange]
)
const ctx = React.useMemo(
() => ({ value: current, setValue }),
[current, setValue]
)
return (
<TabsContext.Provider value={ctx}>
<div
ref={ref}
role="tablist"
className={cn(
"flex w-fit gap-1 rounded-md bg-newt-bg-base p-1",
className
)}
{...props}
>
{children}
</div>
</TabsContext.Provider>
)
}
)
Tabs.displayName = "Tabs"
export interface TabsTriggerProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"value"
> {
value: string
}
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
({ className, value, onClick, ...props }, ref) => {
const ctx = useTabsContext("TabsTrigger")
const active = ctx.value === value
return (
<button
ref={ref}
type="button"
role="tab"
aria-selected={active}
data-state={active ? "active" : "inactive"}
tabIndex={active ? 0 : -1}
className={cn(
"cursor-pointer rounded-sm border-0 bg-transparent px-3.5 py-1.5 text-[13px] font-medium text-newt-text-muted transition-all duration-fast ease-newt",
"data-[state=active]:bg-newt-bg-elevated data-[state=active]:text-newt-text-primary",
"data-[state=inactive]:hover:text-newt-text-secondary",
className
)}
onClick={(e) => {
onClick?.(e)
if (!e.defaultPrevented) ctx.setValue(value)
}}
{...props}
/>
)
}
)
TabsTrigger.displayName = "TabsTrigger"
export interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
value: string
}
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
({ className, value, ...props }, ref) => {
const ctx = useTabsContext("TabsContent")
if (ctx.value !== value) return null
return (
<div
ref={ref}
role="tabpanel"
data-state="active"
className={cn("mt-2", className)}
{...props}
/>
)
}
)
TabsContent.displayName = "TabsContent"
export { Tabs, TabsTrigger, TabsContent }<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, provide, ref, watch } from "vue"
import { cn } from "@/lib/utils"
import { TABS_INJECTION_KEY } from "."
const props = defineProps<{
modelValue?: string
defaultValue?: string
class?: HTMLAttributes["class"]
}>()
const emit = defineEmits<{
(e: "update:modelValue", value: string): void
}>()
const internal = ref<string | undefined>(props.modelValue ?? props.defaultValue)
watch(
() => props.modelValue,
(v) => {
if (v !== undefined) internal.value = v
}
)
const value = computed(() =>
props.modelValue !== undefined ? props.modelValue : internal.value
)
function setValue(next: string) {
if (props.modelValue === undefined) internal.value = next
emit("update:modelValue", next)
}
provide(TABS_INJECTION_KEY, { value, setValue })
</script>
<template>
<div
role="tablist"
:class="cn('flex w-fit gap-1 rounded-md bg-newt-bg-base p-1', props.class)"
>
<slot />
</div>
</template><script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, inject } from "vue"
import { cn } from "@/lib/utils"
import { TABS_INJECTION_KEY } from "."
const props = defineProps<{
value: string
class?: HTMLAttributes["class"]
}>()
const ctx = inject(TABS_INJECTION_KEY)
if (!ctx) throw new Error("<TabsTrigger> must be used within <Tabs>")
const active = computed(() => ctx.value.value === props.value)
</script>
<template>
<button
type="button"
role="tab"
:aria-selected="active"
:data-state="active ? 'active' : 'inactive'"
:tabindex="active ? 0 : -1"
:class="
cn(
'cursor-pointer rounded-sm border-0 bg-transparent px-3.5 py-1.5 text-[13px] font-medium text-newt-text-muted transition-all duration-fast ease-newt',
'data-[state=active]:bg-newt-bg-elevated data-[state=active]:text-newt-text-primary',
'data-[state=inactive]:hover:text-newt-text-secondary',
props.class
)
"
@click="ctx.setValue(props.value)"
>
<slot />
</button>
</template><script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, inject } from "vue"
import { cn } from "@/lib/utils"
import { TABS_INJECTION_KEY } from "."
const props = defineProps<{
value: string
class?: HTMLAttributes["class"]
}>()
const ctx = inject(TABS_INJECTION_KEY)
if (!ctx) throw new Error("<TabsContent> must be used within <Tabs>")
const active = computed(() => ctx.value.value === props.value)
</script>
<template>
<div
v-if="active"
role="tabpanel"
data-state="active"
:class="cn('mt-2', props.class)"
>
<slot />
</div>
</template>import type { ComputedRef, InjectionKey } from "vue"
export { default as Tabs } from "./Tabs.vue"
export { default as TabsTrigger } from "./TabsTrigger.vue"
export { default as TabsContent } from "./TabsContent.vue"
export interface TabsContext {
value: ComputedRef<string | undefined>
setValue: (value: string) => void
}
export const TABS_INJECTION_KEY: InjectionKey<TabsContext> = Symbol("NewtTabs")Update the import paths
The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.
Usage
import { Tabs } from "@/components/ui/tabs"
export function Example() {
return <Tabs />
}<script setup lang="ts">
import { Tabs, TabsTrigger, TabsContent } from "@/components/ui/tabs"
</script>
<template>
<Tabs />
</template>API reference
Tabs
value?string
The active tab, when you control it.
stringdefaultValue?string
The tab active on first render, when you do not.
stringonValueChange?(value: string) => void
Called with the tab that was activated.
(value: string) => voidTabsTrigger
value?string
Which panel this trigger opens.
stringdisabled?boolean
Skipped by arrow-key navigation.
booleanfalseTabsContent
value?string
Which trigger opens this panel.
stringEvery part also accepts the native attributes of the element it renders, and a className that is merged rather than replaced.
Accessibility
It implements the tabs pattern: role="tablist", aria-selected on the triggers, aria-controls and aria-labelledby linking each pair. Only the active trigger is in the tab order; Left and Right move between triggers, Home and End jump to the ends.
Tokens
Tabs reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-tabs.