Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
67 changes: 67 additions & 0 deletions app/components/inspira/examples/animated-tabs/AnimatedTabsDemo.vue
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>
186 changes: 186 additions & 0 deletions app/components/inspira/ui/animated-tabs/AnimatedTabs.vue
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"
Comment on lines +131 to +134
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use tab.value as the key instead of tab.title.

If multiple tabs share the same title, Vue will incorrectly reuse DOM elements, potentially causing animation glitches or state issues. The value property 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
v-for="(tab, idx) in tabs"
:key="tab.title"
type="button"
<button
v-for="(tab, idx) in tabs"
:key="tab.value"
type="button"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/inspira/ui/animated-tabs/AnimatedTabs.vue` around lines 131 -
134, The v-for on the button in AnimatedTabs.vue uses :key="tab.title" which can
conflict for duplicate titles; change the key to use the unique identifier
:key="tab.value" (ensure the tabs array items have a value property) — update
the button v-for (v-for="(tab, idx) in tabs") to use tab.value as the key and
keep existing bindings like selection logic that reference tab.value.

: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>
46 changes: 46 additions & 0 deletions app/components/inspira/ui/animated-tabs/AnimatedTabsFadeInDiv.vue
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Forward contentProps to fallback content component.

Line 39–42 renders tab.content but drops tab.contentProps, so the documented/type-supported props contract is not honored.

Proposed fix
-        <component
-          :is="tab.content"
-          v-if="tab.content"
-        />
+        <component
+          v-if="tab.content"
+          :is="tab.content"
+          v-bind="tab.contentProps"
+        />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<component
:is="tab.content"
v-if="tab.content"
/>
<component
v-if="tab.content"
:is="tab.content"
v-bind="tab.contentProps"
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/inspira/ui/animated-tabs/AnimatedTabsFadeInDiv.vue` around
lines 39 - 42, The fallback rendering of the dynamic component (the <component
:is="tab.content" v-if="tab.content" /> block in AnimatedTabsFadeInDiv.vue)
ignores tab.contentProps; update that component invocation to forward the props
by binding contentProps (e.g., :props="tab.contentProps" or
v-bind="tab.contentProps") so the dynamic/fallback content receives the
documented props; ensure the binding is applied only when tab.contentProps
exists and keep the v-if="tab.content" check.

</slot>
</motion.div>
</div>
</template>
2 changes: 2 additions & 0 deletions app/components/inspira/ui/animated-tabs/index.ts
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";
24 changes: 24 additions & 0 deletions app/components/inspira/ui/animated-tabs/types.ts
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>;
}
Loading