Animated Tabs
A drop-in replacement for shadcn-svelte Tabs powered by svelte-motion and bits-ui. A spring-based sliding indicator animates between triggers using layoutId — no extra config needed.
Installation
Install via the shadcn-svelte CLI from our registry:
npx shadcn-svelte@latest add https://motion.svelte.page/r/animated-tabs.jsonnpx shadcn-svelte@latest add https://motion.svelte.page/r/animated-tabs.jsonThis installs AnimatedTabs alongside your existing components. It automatically pulls in @humanspeak/svelte-motion and bits-ui as dependencies.
Then use it:
<script>
import * as Tabs from '$lib/components/ui/animated-tabs'
</script>
<Tabs.Root value="account">
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="password">Password</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">Account content</Tabs.Content>
<Tabs.Content value="password">Password content</Tabs.Content>
</Tabs.Root><script>
import * as Tabs from '$lib/components/ui/animated-tabs'
</script>
<Tabs.Root value="account">
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="password">Password</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">Account content</Tabs.Content>
<Tabs.Content value="password">Password content</Tabs.Content>
</Tabs.Root>Manual Installation
Alternatively, copy the component source directly from GitHub.
Live Demo
Click the tabs to see the sliding indicator:
Default (Animated)
Account
Make changes to your account here. Click save when you're done.
Without Animation
Set animated={false} on Root to get vanilla shadcn behavior with no motion:
Standard CSS background swap — no animation.
Static tab content for tab 2.
Static tab content for tab 3.
Tabs Only (No Content Animation)
Set animated={false} on Content to keep the sliding indicator but skip the content entrance animation:
The tab indicator slides, but this content appears instantly.
No fade or slide — just an instant swap for the content.
Useful when content animation feels too busy.
Multiple Groups (Unique layoutIds)
Each tab group automatically gets a unique layoutId, so multiple groups on the same page don’t conflict:
Animation Details
| Property | Value | Description |
|---|---|---|
layoutId | Auto-generated per group | Shared layout animation between triggers |
| Transition | Spring (500/30) | stiffness: 500, damping: 30 |
| Indicator | bg-background | Absolute-positioned div behind trigger text |
The sliding indicator uses AnimatePresence + motion.div with layoutId to animate smoothly between the active trigger. Each tab group instance gets its own unique layoutId via a module-level counter, so multiple groups on the same page animate independently.
Accessibility
Full ARIA compliance via bits-ui Tabs primitives:
role="tablist",role="tab",role="tabpanel"aria-selectedon triggers- Roving tabindex (only active trigger is in tab order)
- Arrow keys cycle between triggers
- Home/End jump to first/last trigger
- Enter/Space activate a focused trigger
API
Root
All standard bits-ui Tabs.Root props are supported, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | '' | Active tab value (bindable) |
onValueChange | (value: string) => void | — | Callback when selection changes |
animated | boolean | true | Enable/disable all animation (indicator + content) |
List
Styled wrapper for the trigger container. Accepts all standard HTML div attributes.
Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Tab value (required) |
disabled | boolean | false | Disable this trigger |
animated | boolean | Inherits from Root | Override animation for this trigger |
transition | object | { type: 'spring', stiffness: 500, damping: 30 } | Override the indicator spring transition |
Content
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Tab value (required) |
animated | boolean | Inherits from Root | Override animation for this panel |
initial | object | { opacity: 0, y: 8 } | Override the content entrance initial state |
animate | object | { opacity: 1, y: 0 } | Override the content entrance animate target |
transition | object | { duration: 0.3, ease: 'easeOut' } | Override the content entrance transition |