Skip to content

Instantly share code, notes, and snippets.

@The-LukeZ
Created May 19, 2026 22:44
Show Gist options
  • Select an option

  • Save The-LukeZ/39a244d2c2ad14f0a456547b97fb8b99 to your computer and use it in GitHub Desktop.

Select an option

Save The-LukeZ/39a244d2c2ad14f0a456547b97fb8b99 to your computer and use it in GitHub Desktop.
shadcn-svelte tabs with floating pill
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,
};
<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}
/>
<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}
<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}
/>
<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