slidytabs

A DOM-level utility for animating shadcn <Tabs />

Works with shadcn, shadcn-svelte, and shadcn-vue.

Examples/demo at https://slidytabs.dev

Contents

Why this exists

  • shadcn <Tabs /> jump between positions, which can feel abrupt in motion-oriented UIs. A solution should work with Tabs as-is, not as a wrapper.

  • Separately: Tabs are good for discrete structure; sliders are good for continuous interaction — there isn’t a clear way to combine the two. See the first slider() example

Install

npm i slidytabs

Usage

import { tabs, slider, range } from "slidytabs";

Make tabs slide with tabs()

value is a single index. tabs() works uncontrolled, or can be controlled via shadcn’s value/onValueChange props or via slidytabs’ index-based props.

tabs(options?: {
  value?: number;
  onValueChange?: (value: number) => void;
});

Examples

Use shadcn Tabs the way you normally would, and let your framework pass the root Tabs element to slidytabs.

Account
Account
// Tabs.tsx
import { tabs } from "slidytabs";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shadcn/tabs";

export default () => (
  <Tabs ref={tabs()} defaultValue="account" className="text-center">
    <TabsList>
      <TabsTrigger value="account">Account</TabsTrigger>
      <TabsTrigger value="password">Password</TabsTrigger>
    </TabsList>
    <TabsContent value="account">Account</TabsContent>
    <TabsContent value="password">Password</TabsContent>
  </Tabs>
);
Account
Account
<!-- Tabs.vue -->
<script setup lang="ts">
import { tabs } from "slidytabs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shadcn-vue/tabs";
</script>

<template>
  <Tabs :ref="tabs()" default-value="account" class="text-center">
    <TabsList>
      <TabsTrigger value="account">Account</TabsTrigger>
      <TabsTrigger value="password">Password</TabsTrigger>
    </TabsList>
    <TabsContent value="account">Account</TabsContent>
    <TabsContent value="password">Password</TabsContent>
  </Tabs>
</template>
Account
<!-- Tabs.svelte -->
<script lang="ts">
  import { tabs } from "slidytabs";
  import * as Tabs from "@/shadcn-svelte/tabs";
</script>

<Tabs.Root {@attach tabs()} value="account" class="text-center">
  <Tabs.List>
    <Tabs.Trigger value="account">Account</Tabs.Trigger>
    <Tabs.Trigger value="password">Password</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="account">Account</Tabs.Content>
  <Tabs.Content value="password">Password</Tabs.Content>
</Tabs.Root>

You can control the value via the usual shadcn props.

Correct
Correct
// ShadcnControlled.tsx
import { useState } from "react";
import { tabs } from "slidytabs";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shadcn/tabs";

export default () => {
  const [value, setValue] = useState("correct");
  const updateValue = (newValue: string) =>
    newValue !== "battery" && setValue(newValue);

  return (
    <Tabs value={value} onValueChange={updateValue} ref={tabs()}>
      <TabsList className="[&>:nth-child(3)]:!text-red">
        <TabsTrigger value="correct">Correct</TabsTrigger>
        <TabsTrigger value="horse">Horse</TabsTrigger>
        <TabsTrigger value="battery">Battery</TabsTrigger>
        <TabsTrigger value="staple">Staple</TabsTrigger>
      </TabsList>
      <TabsContent className="text-center" value="correct" children="Correct" />
      <TabsContent className="text-center" value="horse" children="Horse" />
      <TabsContent className="text-center" value="battery" children="Battery" />
      <TabsContent className="text-center" value="staple" children="Staple" />
    </Tabs>
  );
};
Correct
Correct
<!-- ShadcnControlled.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { tabs } from "slidytabs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shadcn-vue/tabs";
const value = ref("correct");
const updateValue = (newValue: string | number) =>
  newValue !== "battery" && (value.value = String(newValue));
</script>

<template>
  <Tabs :ref="tabs()" :model-value="value" @update:model-value="updateValue">
    <TabsList class="[&>:nth-child(3)]:!text-red">
      <TabsTrigger value="correct">Correct</TabsTrigger>
      <TabsTrigger value="horse">Horse</TabsTrigger>
      <TabsTrigger value="battery">Battery</TabsTrigger>
      <TabsTrigger value="staple">Staple</TabsTrigger>
    </TabsList>
    <TabsContent class="text-center" value="correct">Correct</TabsContent>
    <TabsContent class="text-center" value="horse">Horse</TabsContent>
    <TabsContent class="text-center" value="battery">Battery</TabsContent>
    <TabsContent class="text-center" value="staple">Staple</TabsContent>
  </Tabs>
</template>
Correct
<!-- ShadcnControlled.svelte -->
<script lang="ts">
  import { tabs } from "slidytabs";
  import { Tabs, List, Trigger, Content } from "@/shadcn-svelte/tabs";
  let value = $state("correct");
</script>

<Tabs
  {@attach tabs()}
  bind:value={() => value, (next) => next !== "battery" && (value = next)}
>
  <List class="[&>:nth-child(3)]:!text-red">
    <Trigger value="correct">Correct</Trigger>
    <Trigger value="horse">Horse</Trigger>
    <Trigger value="battery">Battery</Trigger>
    <Trigger value="staple">Staple</Trigger>
  </List>
  <Content class="text-center" value="correct">Correct</Content>
  <Content class="text-center" value="horse">Horse</Content>
  <Content class="text-center" value="battery">Battery</Content>
  <Content class="text-center" value="staple">Staple</Content>
</Tabs>

Alternatively, with slidytabs, you can control it using indices. Setting defaultValue helps avoid a flash during hydration.

Correct
Correct
// SlidytabsControlled.tsx
import { useState } from "react";
import { tabs } from "slidytabs";
import { Tabs, TabsList, TabsTrigger } from "@/shadcn/tabs";
const options = ["Correct", "Horse", "Battery", "Stapler"];

export default () => {
  const [index, setIndex] = useState(0);
  const onValueChange = (newIndex: number) =>
    newIndex === 2 ? undefined : setIndex(newIndex);

  return (
    <Tabs defaultValue="Correct" ref={tabs({ value: index, onValueChange })}>
      <TabsList className="[&>:nth-child(3)]:!text-red">
        {options.map((value) => (
          <TabsTrigger key={value} value={value}>
            {value}
          </TabsTrigger>
        ))}
      </TabsList>
      <div className="text-center">{options[index]}</div>
    </Tabs>
  );
};
Correct
Correct
<!-- SlidytabsControlled.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { tabs } from "slidytabs";
import { Tabs, TabsList, TabsTrigger } from "@/shadcn-vue/tabs";

const options = ["Correct", "Horse", "Battery", "Stapler"];
const index = ref(0);
const onValueChange = (next: number) =>
  next === 2 ? undefined : (index.value = next);
</script>

<template>
  <Tabs :ref="tabs({ value: index, onValueChange })" default-value="Correct">
    <TabsList class="[&>:nth-child(3)]:!text-red">
      <TabsTrigger v-for="item in options" :value="item" :key="item">{{
        item
      }}</TabsTrigger>
    </TabsList>
    <div class="text-center">{{ options[index] }}</div>
  </Tabs>
</template>
Correct
<!-- SlidytabsControlled.svelte -->
<script lang="ts">
  import { tabs } from "slidytabs";
  import { Tabs, List, Trigger } from "@/shadcn-svelte/tabs";
  const options = ["Correct", "Horse", "Battery", "Stapler"];

  let index = $state(0);
  const onValueChange = (next: number) =>
    next === 2 ? undefined : (index = next);
</script>

<Tabs {@attach tabs({ value: index, onValueChange })} value="Correct">
  <List class="[&>:nth-child(3)]:!text-red">
    {#each options as value}
      <Trigger {value}>{value}</Trigger>
    {/each}
  </List>
  <div class="text-center">{options[index]}</div>
</Tabs>

Make tabs a slider with slider()

Same as tabs(), with a draggable tab.

sticky: number appears visually as a range slider, with one fixed endpoint. sticky is not compatible with shadcn control props.

slider(options?: {
  value?: number;
  onValueChange?: (value: number) => void;
  sticky?: number;
});

Examples

Same usage as above, with drag support. This example goes deeper into controlled usage and explores some custom styling.

Continuous interaction is valuable when it drives continuous updates elsewhere in the UI, but a traditional slider isn’t always the right abstraction.

// Slider.tsx
import { useState } from "react";
import { slider } from "slidytabs";
import { Tabs, TabsList, TabsTrigger } from "@/shadcn/tabs";
import { sharps, flats } from "@/lib/scales";
const triggerClasses =
  "min-w-0 ring-inset rounded-lg h-full !shadow-none data-[state=active]:bg-zinc-300 data-[state=active]:rounded-none data-[state=inactive]:text-zinc-500";

export default () => {
  const [value, onValueChange] = useState(0);
  return (
    <div className="flex flex-col gap-4">
      {[flats, sharps].map((scale, i) => (
        <Tabs
          key={i}
          defaultValue={scale[value]}
          ref={slider({ value, onValueChange })}
        >
          <TabsList className="p-0 overflow-hidden w-88">
            {scale.map((note) => (
              <TabsTrigger
                key={note}
                value={note}
                children={note}
                className={triggerClasses}
              />
            ))}
          </TabsList>
        </Tabs>
      ))}
    </div>
  );
};
<!-- Slider.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { slider } from "slidytabs";
import { Tabs, TabsList, TabsTrigger } from "@/shadcn-vue/tabs";
import { sharps, flats } from "@/lib/scales";
const triggerClasses =
  "min-w-0 ring-inset rounded-lg h-full !shadow-none data-[state=active]:bg-zinc-300 data-[state=active]:rounded-none data-[state=inactive]:text-zinc-500";

const value = ref(0);
const onValueChange = (newValue: number) => (value.value = Number(newValue));
</script>

<template>
  <div class="flex flex-col gap-4">
    <Tabs
      :ref="slider({ value, onValueChange })"
      :default-value="scale[value]"
      v-for="scale in [flats, sharps]"
    >
      <TabsList class="p-0 overflow-hidden w-88">
        <TabsTrigger
          :class="triggerClasses"
          v-for="note in scale"
          :value="note"
          >{{ note }}</TabsTrigger
        >
      </TabsList>
    </Tabs>
  </div>
</template>
<!-- Slider.svelte -->
<script lang="ts">
  import { slider } from "slidytabs";
  import * as Tabs from "@/shadcn-svelte/tabs";
  import { sharps, flats } from "@/lib/scales";
  const triggerClasses =
    "min-w-0 ring-inset rounded-lg h-full !shadow-none data-[state=active]:bg-zinc-300 data-[state=active]:rounded-none data-[state=inactive]:text-zinc-500";

  let value = $state(0);
  const onValueChange = (newValue: number) => (value = newValue);
</script>

<div class="flex flex-col gap-4">
  {#each [flats, sharps] as scale}
    <Tabs.Root value={scale[value]} {@attach slider({ value, onValueChange })}>
      <Tabs.List class="p-0 overflow-hidden w-88">
        {#each scale as note}
          <Tabs.Trigger class={triggerClasses} value={note}>{note}</Tabs.Trigger
          >
        {/each}
      </Tabs.List>
    </Tabs.Root>
  {/each}
</div>

The sticky param lets you define a permanent endpoint, while still passing around a single index.

Choose sticky:
Sticky applied:
Choose sticky:
Sticky applied:
// Sticky.tsx
import { useState } from "react";
import { slider, type SliderOptions } from "slidytabs";
import { Tabs, TabsList, TabsTrigger } from "@/shadcn/tabs";

const Slider = (sliderOptions: SliderOptions) => (
  <Tabs defaultValue={"5"} ref={slider(sliderOptions)}>
    <TabsList>
      {Array.from({ length: 11 }, (_, i) => (
        <TabsTrigger className="min-w-0" key={i} value={i.toString()}>
          {i}
        </TabsTrigger>
      ))}
    </TabsList>
  </Tabs>
);

export default () => {
  const [sticky, setSticky] = useState(5);

  return (
    <div className="flex flex-col gap-3 text-sm">
      <div className="flex flex-col gap-1.5">
        Choose sticky:
        <Slider value={sticky} onValueChange={setSticky} />
      </div>
      <div className="flex flex-col gap-1.5">
        Sticky applied:
        <Slider sticky={sticky} />
      </div>
    </div>
  );
};
Coming soon ...
Choose sticky:
Sticky applied:
<!-- Sticky.svelte -->
<script lang="ts">
  import { slider, type SliderOptions } from "slidytabs";
  import * as Tabs from "@/shadcn-svelte/tabs";

  let sticky = $state(5);
  const onValueChange = (next: number) => (sticky = next);
</script>

{#snippet Slider(sliderOptions: SliderOptions)}
  <Tabs.Root value={sticky.toString()} {@attach slider(sliderOptions)}>
    <Tabs.List>
      {#each { length: 11 }, i}
        <Tabs.Trigger class="min-w-0" value={i.toString()}>{i}</Tabs.Trigger>
      {/each}
    </Tabs.List>
  </Tabs.Root>
{/snippet}

<div class="flex flex-col gap-3 text-sm">
  <div class="flex flex-col gap-1.5">
    Choose sticky:
    {@render Slider({ value: sticky, onValueChange })}
  </div>
  <div class="flex flex-col gap-1.5">
    Sticky applied:
    {@render Slider({ sticky })}
  </div>
</div>

A vertical example

// Vertical.tsx
import { slider } from "slidytabs";
import { Tabs, TabsList, TabsTrigger } from "@/shadcn/tabs";

export default () => (
  <Tabs ref={slider()} defaultValue="account" orientation="vertical">
    <TabsList className="h-full flex-col items-stretch">
      <TabsTrigger value="account">Account</TabsTrigger>
      <TabsTrigger value="password">Password</TabsTrigger>
    </TabsList>
  </Tabs>
);
<!-- Vertical.vue -->
<script setup lang="ts">
import { slider } from "slidytabs";
import { Tabs, TabsList, TabsTrigger } from "@/shadcn-vue/tabs";
</script>

<template>
  <Tabs :ref="slider()" default-value="account" orientation="vertical">
    <TabsList class="h-full flex-col items-stretch">
      <TabsTrigger value="account">Account</TabsTrigger>
      <TabsTrigger value="password">Password</TabsTrigger>
    </TabsList>
  </Tabs>
</template>
<!-- Vertical.svelte -->
<script lang="ts">
  import { slider } from "slidytabs";
  import * as Tabs from "@/shadcn-svelte/tabs";
</script>

<Tabs.Root {@attach slider()} value="account" orientation="vertical">
  <Tabs.List class="h-full flex-col items-stretch">
    <Tabs.Trigger value="account">Account</Tabs.Trigger>
    <Tabs.Trigger value="password">Password</Tabs.Trigger>
  </Tabs.List>
</Tabs.Root>

Make tabs a range slider with range()

value is a pair of indices [start, end]. Not compatible with shadcn control props.

push: boolean lets one endpoint push the other.

range(options?: {
  value: [number, number];
  onValueChange?: (value: [number, number]) => void;
  push?: boolean;
});

Examples

Similar usage as above, but now we’re passing around tuples ([number, number]). At the moment, there’s no mechanism to avoid a hydration flash when using range().

// Range.tsx
import { useState } from "react";
import { range, type RangeValue } from "slidytabs";
import { Tabs, TabsList, TabsTrigger } from "@/shadcn/tabs";

export default () => {
  const [value, onValueChange] = useState<RangeValue>([4, 6]);
  return (
    <Tabs ref={range({ value, onValueChange })}>
      <TabsList>
        {Array.from({ length: 11 }, (_, i) => (
          <TabsTrigger key={i} value={String(i)} className="min-w-0">
            {i}
          </TabsTrigger>
        ))}
      </TabsList>
    </Tabs>
  );
};
<!-- Range.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { range, type RangeValue } from "slidytabs";
import { Tabs, TabsList, TabsTrigger } from "@/shadcn-vue/tabs";

const value = ref<RangeValue>([4, 6]);
const onValueChange = (newValue: RangeValue) => (value.value = newValue);
</script>

<template>
  <Tabs :ref="range({ value, onValueChange })">
    <TabsList>
      <TabsTrigger class="min-w-0" v-for="i in 11" :value="i - 1">{{
        i - 1
      }}</TabsTrigger>
    </TabsList>
  </Tabs>
</template>
<!-- Range.svelte -->
<script lang="ts">
  import { range, type RangeValue } from "slidytabs";
  import * as Tabs from "@/shadcn-svelte/tabs";

  let value: RangeValue = $state([4, 6]);
  const onValueChange = (next: RangeValue) => (value = next);
</script>

<Tabs.Root {@attach range({ value, onValueChange })}>
  <Tabs.List>
    {#each { length: 11 }, i}
      <Tabs.Trigger class="min-w-0" value={i.toString()}>{i}</Tabs.Trigger>
    {/each}
  </Tabs.List>
</Tabs.Root>

Use push to let one endpoint push the other.

// Push.tsx
import { useState } from "react";
import { range, type RangeValue } from "slidytabs";
import { Tabs, TabsList, TabsTrigger } from "@/shadcn/tabs";

export default () => {
  const [value, onValueChange] = useState<RangeValue>([4, 6]);
  return (
    <Tabs ref={range({ value, onValueChange, push: true })}>
      <TabsList>
        {Array.from({ length: 11 }, (_, i) => (
          <TabsTrigger key={i} value={String(i)} className="min-w-0">
            {i}
          </TabsTrigger>
        ))}
      </TabsList>
    </Tabs>
  );
};
<!-- Push.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { range, type RangeValue } from "slidytabs";
import { Tabs, TabsList, TabsTrigger } from "@/shadcn-vue/tabs";

const value = ref<RangeValue>([4, 6]);
const onValueChange = (newValue: RangeValue) => (value.value = newValue);
</script>

<template>
  <Tabs :ref="range({ value, onValueChange, push: true })">
    <TabsList>
      <TabsTrigger class="min-w-0" v-for="i in 11" :value="i - 1">{{
        i - 1
      }}</TabsTrigger>
    </TabsList>
  </Tabs>
</template>
<!-- Push.svelte -->
<script lang="ts">
  import { range, type RangeValue } from "slidytabs";
  import * as Tabs from "@/shadcn-svelte/tabs";

  let value: RangeValue = $state([4, 6]);
  const onValueChange = (next: RangeValue) => (value = next);
</script>

<Tabs.Root {@attach range({ value, onValueChange, push: true })}>
  <Tabs.List>
    {#each { length: 11 }, i}
      <Tabs.Trigger class="min-w-0" value={i.toString()}>{i}</Tabs.Trigger>
    {/each}
  </Tabs.List>
</Tabs.Root>
Other projects: oklch.beauty
Boston skyline Made with love in Camberville, MA