Contents
Why this exists
-
tabs(): 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 or replacement. -
slider()/range(): Sometimes you want the semantics of a radio button or select, with the linearity and tactility of a slider. This comes up often when selecting from small, discrete sets where sliders feel almost right, but slightly wrong. Think clothing sizes, days of the week, months, tip amounts, or school grades. See this UX StackExchange discussion.
Install
npm i slidytabsUsage
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.
// Tabs.tsximport { 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>); <!-- 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> <!-- 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.
// ShadcnControlled.tsximport { 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> );}; <!-- 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> <!-- 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.
// SlidytabsControlled.tsximport { 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> );}; <!-- 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> <!-- 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 the right abstraction here.
// Slider.tsximport { 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.
// Sticky.tsximport { 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> );}; <!-- Sticky.vue --><script setup lang="tsx">import { ref } from "vue";import { slider } from "slidytabs";import { Tabs, TabsList, TabsTrigger } from "@/shadcn-vue/tabs";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 Slider = ({ value, onValueChange, sticky,}: { value?: number; onValueChange?: (value: number) => void; sticky?: number;}) => ( <Tabs defaultValue={"5"} ref={slider({ value, onValueChange, sticky })}> <TabsList class="p-0 overflow-hidden"> {Array.from({ length: 11 }, (_, i) => ( <TabsTrigger key={i} value={i.toString()} class={triggerClasses}> {i} </TabsTrigger> ))} </TabsList> </Tabs>);const sticky = ref(5);</script><template> <div class="flex flex-col gap-3 text-sm"> <div class="flex flex-col gap-1.5"> Choose sticky: <Slider :value="sticky" :onValueChange="(v: number) => (sticky = v)" /> </div> <div class="flex flex-col gap-1.5"> Sticky applied: <Slider :sticky="sticky" /> </div> </div></template> <!-- 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.tsximport { 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.tsximport { 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.tsximport { 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>