Skip to content

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.

Root class newt-tabs
import { 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 tabs
npx newtui@latest add tabs
yarn dlx newtui@latest add tabs
bunx newtui@latest add tabs

Copy 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

PropType
value?string

The active tab, when you control it.

Typestring
defaultValue?string

The tab active on first render, when you do not.

Typestring
onValueChange?(value: string) => void

Called with the tab that was activated.

Type(value: string) => void

TabsTrigger

PropType
value?string

Which panel this trigger opens.

Typestring
disabled?boolean

Skipped by arrow-key navigation.

Typeboolean
Defaultfalse

TabsContent

PropType
value?string

Which trigger opens this panel.

Typestring

Every 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.