Created
May 19, 2026 22:44
-
-
Save The-LukeZ/39a244d2c2ad14f0a456547b97fb8b99 to your computer and use it in GitHub Desktop.
shadcn-svelte tabs with floating pill
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Root from "./tabs.svelte"; | |
| import Content from "./tabs-content.svelte"; | |
| import List, { tabsListVariants, type TabsListVariant } from "./tabs-list.svelte"; | |
| import Trigger from "./tabs-trigger.svelte"; | |
| export { | |
| Root, | |
| Content, | |
| List, | |
| Trigger, | |
| tabsListVariants, | |
| type TabsListVariant, | |
| // | |
| Root as Tabs, | |
| Content as TabsContent, | |
| List as TabsList, | |
| Trigger as TabsTrigger, | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script lang="ts"> | |
| import { cn } from "$lib/utils.js"; | |
| import { Tabs as TabsPrimitive } from "bits-ui"; | |
| let { ref = $bindable(null), class: className, ...restProps }: TabsPrimitive.ContentProps = $props(); | |
| </script> | |
| <TabsPrimitive.Content | |
| bind:ref | |
| data-slot="tabs-content" | |
| class={cn("flex-1 text-sm outline-none", className)} | |
| {...restProps} | |
| /> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script lang="ts" module> | |
| import { tv, type VariantProps } from "tailwind-variants"; | |
| export const tabsListVariants = tv({ | |
| base: "relative rounded-4xl p-[3px] group-data-horizontal/tabs:h-9 group-data-vertical/tabs:rounded-2xl data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col", | |
| variants: { | |
| variant: { | |
| default: "cn-tabs-list-variant-default bg-muted", | |
| line: "cn-tabs-list-variant-line gap-1 bg-transparent", | |
| pill: "bg-muted", | |
| }, | |
| }, | |
| defaultVariants: { | |
| variant: "default", | |
| }, | |
| }); | |
| export type TabsListVariant = VariantProps<typeof tabsListVariants>["variant"]; | |
| </script> | |
| <script lang="ts"> | |
| import { cn } from "$lib/utils.js"; | |
| import { Tabs as TabsPrimitive } from "bits-ui"; | |
| let { | |
| ref = $bindable(null), | |
| variant = "default", | |
| class: className, | |
| children, | |
| ...restProps | |
| }: TabsPrimitive.ListProps & { | |
| variant?: TabsListVariant; | |
| } = $props(); | |
| let listEl = $state<HTMLElement | null>(null); | |
| let pillLeft = $state(0); | |
| let pillTop = $state(0); | |
| let pillWidth = $state(0); | |
| let pillHeight = $state(0); | |
| let pillVisible = $state(false); | |
| let pillReady = $state(false); | |
| function measurePill() { | |
| if (!listEl) return; | |
| const active = listEl.querySelector<HTMLElement>("[data-state='active']"); | |
| if (!active) return; | |
| const listRect = listEl.getBoundingClientRect(); | |
| const triggerRect = active.getBoundingClientRect(); | |
| pillLeft = triggerRect.left - listRect.left; | |
| pillTop = triggerRect.top - listRect.top; | |
| pillWidth = triggerRect.width; | |
| pillHeight = triggerRect.height; | |
| pillVisible = true; | |
| } | |
| $effect(() => { | |
| if (variant === "pill") ref = listEl; | |
| }); | |
| $effect(() => { | |
| if (variant !== "pill" || !listEl) return; | |
| measurePill(); | |
| requestAnimationFrame(() => { | |
| pillReady = true; | |
| }); | |
| const observer = new MutationObserver(() => measurePill()); | |
| observer.observe(listEl, { | |
| attributes: true, | |
| attributeFilter: ["data-state"], | |
| subtree: true, | |
| }); | |
| return () => observer.disconnect(); | |
| }); | |
| </script> | |
| {#if variant === "pill"} | |
| {#snippet pillListChild({ props }: { props: Record<string, unknown> })} | |
| <div | |
| bind:this={listEl} | |
| data-slot="tabs-list" | |
| data-variant={variant} | |
| class={cn(tabsListVariants({ variant }), className)} | |
| {...props} | |
| > | |
| <span | |
| aria-hidden="true" | |
| class={cn( | |
| "pointer-events-none absolute rounded-3xl bg-background shadow-sm dark:border dark:border-input dark:bg-input/30", | |
| !pillVisible && "hidden", | |
| pillVisible && !pillReady && "transition-none", | |
| pillVisible && pillReady && "transition-[left,top,width,height] duration-200 ease-in-out", | |
| )} | |
| style="left:{pillLeft}px;top:{pillTop}px;width:{pillWidth}px;height:{pillHeight}px;" | |
| ></span> | |
| {@render children?.()} | |
| </div> | |
| {/snippet} | |
| <TabsPrimitive.List child={pillListChild} {...restProps} /> | |
| {:else} | |
| <TabsPrimitive.List | |
| bind:ref | |
| data-slot="tabs-list" | |
| data-variant={variant} | |
| class={cn(tabsListVariants({ variant }), className)} | |
| {...restProps} | |
| > | |
| {@render children?.()} | |
| </TabsPrimitive.List> | |
| {/if} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script lang="ts"> | |
| import { cn } from "$lib/utils.js"; | |
| import { Tabs as TabsPrimitive } from "bits-ui"; | |
| let { ref = $bindable(null), class: className, ...restProps }: TabsPrimitive.TriggerProps = $props(); | |
| </script> | |
| <TabsPrimitive.Trigger | |
| bind:ref | |
| data-slot="tabs-trigger" | |
| class={cn( | |
| "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-2xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | |
| "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", | |
| "group-data-[variant=pill]/tabs-list:data-active:bg-transparent group-data-[variant=pill]/tabs-list:dark:data-active:border-transparent group-data-[variant=pill]/tabs-list:dark:data-active:bg-transparent", | |
| "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground", | |
| "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", | |
| className, | |
| )} | |
| {...restProps} | |
| /> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script lang="ts"> | |
| import { cn } from "$lib/utils.js"; | |
| import { Tabs as TabsPrimitive } from "bits-ui"; | |
| let { | |
| ref = $bindable(null), | |
| value = $bindable(""), | |
| class: className, | |
| ...restProps | |
| }: TabsPrimitive.RootProps = $props(); | |
| </script> | |
| <TabsPrimitive.Root | |
| bind:ref | |
| bind:value | |
| data-slot="tabs" | |
| class={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)} | |
| {...restProps} | |
| /> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment