-
Notifications
You must be signed in to change notification settings - Fork 273
feat: add animated tabs component #275
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| <script lang="ts" setup> | ||
| import AnimatedTabsDemo from "~/components/inspira/examples/animated-tabs/AnimatedTabsDemo.vue"; | ||
| </script> | ||
|
|
||
| <template> | ||
| <ComponentPlayground> | ||
| <template #component> | ||
| <AnimatedTabsDemo /> | ||
| </template> | ||
| </ComponentPlayground> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| <script lang="ts" setup> | ||
| import type { AnimatedTab } from "../../ui/animated-tabs"; | ||
| import { cn } from "@inspira-ui/plugins"; | ||
|
|
||
| const tabs: AnimatedTab[] = [ | ||
| { | ||
| title: "Product", | ||
| value: "product", | ||
| panelClassName: "bg-gradient-to-br from-purple-700 to-violet-900", | ||
| }, | ||
| { | ||
| title: "Services", | ||
| value: "services", | ||
| panelClassName: "bg-gradient-to-br from-sky-700 to-indigo-900", | ||
| }, | ||
| { | ||
| title: "Playground", | ||
| value: "playground", | ||
| panelClassName: "bg-gradient-to-br from-emerald-700 to-teal-900", | ||
| }, | ||
| { | ||
| title: "Content", | ||
| value: "content", | ||
| panelClassName: "bg-gradient-to-br from-rose-700 to-fuchsia-900", | ||
| }, | ||
| { | ||
| title: "Random", | ||
| value: "random", | ||
| panelClassName: "bg-gradient-to-br from-amber-600 to-orange-900", | ||
| }, | ||
| ]; | ||
|
|
||
| const modelValue = ref(tabs[0]?.value ?? ""); | ||
| </script> | ||
|
|
||
| <template> | ||
| <div | ||
| class="relative mx-auto flex h-[20rem] w-full max-w-5xl flex-col items-start justify-start [perspective:1000px] md:h-[40rem]" | ||
| > | ||
| <AnimatedTabs | ||
| v-model="modelValue" | ||
| :tabs="tabs" | ||
| > | ||
| <template #content="{ tab, active: slotActive }"> | ||
| <div | ||
| :class=" | ||
| cn( | ||
| 'relative h-full w-full overflow-hidden rounded-2xl p-10 text-xl font-bold text-white md:text-4xl', | ||
| tab.panelClassName, | ||
| ) | ||
| " | ||
| > | ||
| <p>{{ tab.title }} tab</p> | ||
| <div class="mt-6 max-w-xl text-base font-medium text-white/90 md:text-lg"> | ||
| <p class="font-semibold">This area is fully slot-based.</p> | ||
| <p class="mt-2">Render anything here: lists, forms, tables, custom components…</p> | ||
| <ul class="mt-4 list-disc space-y-2 pl-6"> | ||
| <li>Active value: {{ slotActive?.value ?? "" }}</li> | ||
| <li>Animations powered by motion-v</li> | ||
| <li>Stacked cards + hover depth effect</li> | ||
| </ul> | ||
| </div> | ||
| </div> | ||
| </template> | ||
| </AnimatedTabs> | ||
| </div> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| <script setup lang="ts"> | ||
| import type { AnimatedTab } from "./types"; | ||
| import { cn } from "@inspira-ui/plugins"; | ||
| import { motion } from "motion-v"; | ||
|
|
||
| const props = withDefaults( | ||
| defineProps<{ | ||
| tabs: AnimatedTab[]; | ||
| modelValue?: string; | ||
| containerClassName?: string; | ||
| activeTabClassName?: string; | ||
| tabClassName?: string; | ||
| contentClassName?: string; | ||
| }>(), | ||
| { | ||
| containerClassName: "", | ||
| activeTabClassName: "", | ||
| tabClassName: "", | ||
| contentClassName: "", | ||
| }, | ||
| ); | ||
|
|
||
| const emit = defineEmits<{ | ||
| "update:modelValue": [value: string]; | ||
| }>(); | ||
|
|
||
| const isControlled = computed(() => props.modelValue !== undefined); | ||
| const uncontrolledValue = ref<string | null>(props.tabs[0]?.value ?? null); | ||
| const selectedValue = computed(() => | ||
| isControlled.value ? props.modelValue : uncontrolledValue.value, | ||
| ); | ||
| const id = useId(); | ||
| const pillLayoutId = computed(() => `animated-tabs-pill-${id}`); | ||
|
|
||
| const reorderTabs = (tabs: AnimatedTab[], value: string | null | undefined) => { | ||
| const next = tabs.slice(); | ||
| if (!value) return next; | ||
|
|
||
| const idx = next.findIndex((t) => t.value === value); | ||
| if (idx <= 0) return next; | ||
|
|
||
| const selected = next.splice(idx, 1)[0]; | ||
| if (!selected) return next; | ||
|
|
||
| next.unshift(selected); | ||
| return next; | ||
| }; | ||
|
|
||
| const stackedTabs = ref<AnimatedTab[]>(reorderTabs(props.tabs, selectedValue.value)); | ||
| const active = ref<AnimatedTab | null>(stackedTabs.value[0] ?? null); | ||
| const pointerHovering = ref(false); | ||
| const programmaticHovering = ref(false); | ||
| const hovering = computed(() => pointerHovering.value || programmaticHovering.value); | ||
|
|
||
| let programmaticHoverTimer: ReturnType<typeof setTimeout> | null = null; | ||
| const triggerProgrammaticHover = () => { | ||
| if (pointerHovering.value) return; | ||
|
|
||
| programmaticHovering.value = true; | ||
| if (programmaticHoverTimer) clearTimeout(programmaticHoverTimer); | ||
| programmaticHoverTimer = setTimeout(() => { | ||
| programmaticHoverTimer = null; | ||
| // If the user moved the mouse onto the component while the timer was running, | ||
| // keep the hover state driven by the pointer. | ||
| if (!pointerHovering.value) programmaticHovering.value = false; | ||
| }, 320); | ||
| }; | ||
|
|
||
| onScopeDispose(() => { | ||
| if (programmaticHoverTimer) clearTimeout(programmaticHoverTimer); | ||
| }); | ||
|
|
||
| watch( | ||
| [() => props.tabs, () => selectedValue.value], | ||
| ([nextTabs, value]) => { | ||
| const nextStack = reorderTabs(nextTabs, value); | ||
| stackedTabs.value = nextStack; | ||
| active.value = nextStack[0] ?? null; | ||
|
|
||
| if (!isControlled.value) { | ||
| // If the selected value disappears (tabs list replaced), keep internal state valid. | ||
| if (!value || !nextTabs.some((t) => t.value === value)) { | ||
| uncontrolledValue.value = nextStack[0]?.value ?? null; | ||
| } | ||
| } | ||
| }, | ||
| { deep: false, immediate: true }, | ||
| ); | ||
|
|
||
| // When controlled externally, a modelValue change can happen without the pointer hovering | ||
| // over the tabs. Briefly simulate hover to match the "click while hovering" visuals. | ||
| watch( | ||
| () => props.modelValue, | ||
| (value, oldValue) => { | ||
| if (!isControlled.value) return; | ||
| if (value === undefined) return; | ||
| if (oldValue === undefined) return; // skip initial mount | ||
| if (value === oldValue) return; | ||
|
|
||
| triggerProgrammaticHover(); | ||
| }, | ||
| ); | ||
|
|
||
| const selectTab = (idx: number) => { | ||
| const selected = props.tabs[idx]; | ||
| if (!selected) return; | ||
|
|
||
| if (isControlled.value) { | ||
| emit("update:modelValue", selected.value); | ||
| return; | ||
| } | ||
|
|
||
| uncontrolledValue.value = selected.value; | ||
| }; | ||
| </script> | ||
|
|
||
| <template> | ||
| <div | ||
| class="flex h-full w-full flex-col" | ||
| @mouseenter="pointerHovering = true" | ||
| @mouseleave="pointerHovering = false" | ||
| > | ||
| <div | ||
| :class=" | ||
| cn( | ||
| 'relative flex w-full max-w-full shrink-0 flex-row items-center justify-start overflow-auto [-ms-overflow-style:none] [-webkit-overflow-scrolling:touch] [perspective:1000px] [scrollbar-width:none] sm:overflow-visible [&::-webkit-scrollbar]:hidden', | ||
| containerClassName, | ||
| ) | ||
| " | ||
| > | ||
| <button | ||
| v-for="(tab, idx) in tabs" | ||
| :key="tab.title" | ||
| type="button" | ||
| :class="cn('relative rounded-full px-4 py-2', tabClassName)" | ||
| :style="{ transformStyle: 'preserve-3d' }" | ||
| @click="selectTab(idx)" | ||
| > | ||
| <motion.div | ||
| v-if="active?.value === tab.value" | ||
| :layout-id="pillLayoutId" | ||
| :transition="{ type: 'spring', bounce: 0.3, duration: 0.6 }" | ||
| :class=" | ||
| cn( | ||
| 'pointer-events-none absolute inset-0 rounded-full bg-gray-200 dark:bg-zinc-800', | ||
| activeTabClassName, | ||
| ) | ||
| " | ||
| /> | ||
|
|
||
| <span class="relative block text-black dark:text-white"> | ||
| {{ tab.title }} | ||
| </span> | ||
| </button> | ||
| </div> | ||
|
|
||
| <div class="relative mt-24 min-h-0 flex-1"> | ||
| <AnimatedTabsFadeInDiv | ||
| :id="id" | ||
| :key="active?.value ?? 'empty'" | ||
| :tabs="stackedTabs" | ||
| :active="active" | ||
| :hovering="hovering" | ||
| :class-name="contentClassName" | ||
| > | ||
| <template #content="{ tab, active: slotActive, hovering: slotHovering }"> | ||
| <slot | ||
| name="content" | ||
| :tab="tab" | ||
| :active="slotActive" | ||
| :hovering="slotHovering" | ||
| > | ||
| <component | ||
| :is="tab.content" | ||
| v-if="tab.content" | ||
| v-bind="tab.contentProps || {}" | ||
| :tab="tab" | ||
| :active="slotActive" | ||
| :hovering="slotHovering" | ||
| /> | ||
| </slot> | ||
| </template> | ||
| </AnimatedTabsFadeInDiv> | ||
| </div> | ||
| </div> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,46 @@ | ||||||||||||||||||||
| <script setup lang="ts"> | ||||||||||||||||||||
| import type { AnimatedTab } from "./types"; | ||||||||||||||||||||
| import { cn } from "@inspira-ui/plugins"; | ||||||||||||||||||||
| import { motion } from "motion-v"; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const props = defineProps<{ | ||||||||||||||||||||
| id: string; | ||||||||||||||||||||
| className?: string; | ||||||||||||||||||||
| tabs: AnimatedTab[]; | ||||||||||||||||||||
| active: AnimatedTab | null; | ||||||||||||||||||||
| hovering?: boolean; | ||||||||||||||||||||
| }>(); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const isActive = (tab: AnimatedTab) => tab.value === props.tabs[0]?.value; | ||||||||||||||||||||
| </script> | ||||||||||||||||||||
|
|
||||||||||||||||||||
| <template> | ||||||||||||||||||||
| <div class="relative h-full w-full"> | ||||||||||||||||||||
| <motion.div | ||||||||||||||||||||
| v-for="(tab, idx) in tabs" | ||||||||||||||||||||
| :key="tab.value" | ||||||||||||||||||||
| :layout-id="`animated-tabs-panel-${props.id}-${tab.value}`" | ||||||||||||||||||||
| :style="{ | ||||||||||||||||||||
| scale: 1 - idx * 0.1, | ||||||||||||||||||||
| top: hovering ? `${idx * -50}px` : '0px', | ||||||||||||||||||||
| willChange: 'transform, top', | ||||||||||||||||||||
| zIndex: -idx, | ||||||||||||||||||||
| opacity: idx < 3 ? 1 - idx * 0.1 : 0, | ||||||||||||||||||||
| }" | ||||||||||||||||||||
| :animate="{ y: isActive(tab) ? [0, 40, 0] : 0 }" | ||||||||||||||||||||
| :class="cn('absolute top-0 left-0 h-full w-full will-change-transform', className)" | ||||||||||||||||||||
| > | ||||||||||||||||||||
| <slot | ||||||||||||||||||||
| name="content" | ||||||||||||||||||||
| :tab="tab" | ||||||||||||||||||||
| :active="active" | ||||||||||||||||||||
| :hovering="hovering" | ||||||||||||||||||||
| > | ||||||||||||||||||||
| <component | ||||||||||||||||||||
| :is="tab.content" | ||||||||||||||||||||
| v-if="tab.content" | ||||||||||||||||||||
| /> | ||||||||||||||||||||
|
Comment on lines
+39
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Forward Line 39–42 renders Proposed fix- <component
- :is="tab.content"
- v-if="tab.content"
- />
+ <component
+ v-if="tab.content"
+ :is="tab.content"
+ v-bind="tab.contentProps"
+ />📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| </slot> | ||||||||||||||||||||
| </motion.div> | ||||||||||||||||||||
| </div> | ||||||||||||||||||||
| </template> | ||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export { default as AnimatedTabs } from "./AnimatedTabs.vue"; | ||
| export type { AnimatedTab } from "./types"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import type { Component } from "vue"; | ||
|
|
||
| export interface AnimatedTab { | ||
| title: string; | ||
| value: string; | ||
| /** | ||
| * Optional class name for the panel/card background (used by demos). | ||
| * Prefer keeping design decisions in the consumer via `#content` slot. | ||
| */ | ||
| panelClassName?: string; | ||
| /** | ||
| * Optional tab panel content. For maximum flexibility, prefer the `#content` slot. | ||
| * When provided, it should be a Vue component (including functional components). | ||
| * | ||
| * Note: if the consumer provides the `#content` slot, that slot takes precedence | ||
| * and this `content` component will not be rendered. | ||
| */ | ||
| content?: Component; | ||
| /** | ||
| * Optional props to pass to the content component. | ||
| * These props will be passed to the component when it's rendered. | ||
| */ | ||
| contentProps?: Record<string, any>; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use
tab.valueas the key instead oftab.title.If multiple tabs share the same title, Vue will incorrectly reuse DOM elements, potentially causing animation glitches or state issues. The
valueproperty is the unique identifier used for selection and should be used for keying.Proposed fix
<button v-for="(tab, idx) in tabs" - :key="tab.title" + :key="tab.value" type="button"📝 Committable suggestion
🤖 Prompt for AI Agents