diff --git a/packages/storybook/src/atoms/underline-tabs/fields.ts b/packages/storybook/src/atoms/underline-tabs/fields.ts
new file mode 100644
index 00000000..8f22fe6d
--- /dev/null
+++ b/packages/storybook/src/atoms/underline-tabs/fields.ts
@@ -0,0 +1 @@
+export const FIELDS = ['value', 'onValueChange', 'loop', 'disabled'] as string[];
diff --git a/packages/storybook/src/atoms/underline-tabs/underline-tabs.stories.svelte b/packages/storybook/src/atoms/underline-tabs/underline-tabs.stories.svelte
new file mode 100644
index 00000000..9a0ee591
--- /dev/null
+++ b/packages/storybook/src/atoms/underline-tabs/underline-tabs.stories.svelte
@@ -0,0 +1,107 @@
+
+
+
+ {#snippet template(args)}
+
+
+
+ Home
+ Profile
+ Settings
+
+
+
+
Home Content
+
+ Welcome to the home tab. This is where your main content lives.
+
+
+
+
+
+
Profile Content
+
+ Manage your profile settings and personal information here.
+
+
+
+
+
+
Settings Content
+
Configure your application settings and preferences.
+
+
+
+
+ {/snippet}
+
+
+
+ {#snippet template(args)}
+
+
+
+ Home
+ Profile
+ Settings
+
+
+
+
Home content is shown here.
+
+
+
+
+
Profile content is shown here.
+
+
+
+
+
Settings content (disabled).
+
+
+
+
+ {/snippet}
+
diff --git a/packages/ui/src/custom/underline-tabs/index.ts b/packages/ui/src/custom/underline-tabs/index.ts
new file mode 100644
index 00000000..a21e19e3
--- /dev/null
+++ b/packages/ui/src/custom/underline-tabs/index.ts
@@ -0,0 +1,16 @@
+import Root from './underline-tabs.svelte';
+import Content from './underline-tabs-content.svelte';
+import List from './underline-tabs-list.svelte';
+import Trigger from './underline-tabs-trigger.svelte';
+
+export {
+ Root,
+ Content,
+ List,
+ Trigger,
+ //
+ Root as UnderlineTabs,
+ Content as UnderlineTabsContent,
+ List as UnderlineTabsList,
+ Trigger as UnderlineTabsTrigger
+};
diff --git a/packages/ui/src/custom/underline-tabs/underline-tabs-content.svelte b/packages/ui/src/custom/underline-tabs/underline-tabs-content.svelte
new file mode 100644
index 00000000..79690cba
--- /dev/null
+++ b/packages/ui/src/custom/underline-tabs/underline-tabs-content.svelte
@@ -0,0 +1,13 @@
+
+
+
diff --git a/packages/ui/src/custom/underline-tabs/underline-tabs-list.svelte b/packages/ui/src/custom/underline-tabs/underline-tabs-list.svelte
new file mode 100644
index 00000000..db62dc1f
--- /dev/null
+++ b/packages/ui/src/custom/underline-tabs/underline-tabs-list.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
+
+
diff --git a/packages/ui/src/custom/underline-tabs/underline-tabs-trigger.svelte b/packages/ui/src/custom/underline-tabs/underline-tabs-trigger.svelte
new file mode 100644
index 00000000..65e1d8eb
--- /dev/null
+++ b/packages/ui/src/custom/underline-tabs/underline-tabs-trigger.svelte
@@ -0,0 +1,59 @@
+
+
+
+
+ {@render children?.()}
+
+ {#if state.rootState.hoveredTab === value}
+
+ {/if}
+ {#if state.rootState.opts.value.current === value}
+
+ {/if}
+
diff --git a/packages/ui/src/custom/underline-tabs/underline-tabs.svelte b/packages/ui/src/custom/underline-tabs/underline-tabs.svelte
new file mode 100644
index 00000000..12756b16
--- /dev/null
+++ b/packages/ui/src/custom/underline-tabs/underline-tabs.svelte
@@ -0,0 +1,33 @@
+
+
+
diff --git a/packages/ui/src/custom/underline-tabs/underline-tabs.svelte.ts b/packages/ui/src/custom/underline-tabs/underline-tabs.svelte.ts
new file mode 100644
index 00000000..b176b9a2
--- /dev/null
+++ b/packages/ui/src/custom/underline-tabs/underline-tabs.svelte.ts
@@ -0,0 +1,88 @@
+import { Context } from 'runed';
+import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
+import type { Tabs as TabsPrimitive } from 'bits-ui';
+import { crossfade } from 'svelte/transition';
+
+export const [send, receive] = crossfade({
+ duration: (d) => Math.sqrt(d * 200)
+});
+
+type UnderlineTabsRootProps = WritableBoxedValues<{
+ value: string;
+}> &
+ ReadableBoxedValues<{
+ id: string;
+ }>;
+
+class UnderlineTabsRootState {
+ hoveredTab = $state(null);
+ isHovered = $state(false);
+ constructor(readonly opts: UnderlineTabsRootProps) {
+ this.hoveredTab = this.opts.value.current;
+ }
+}
+
+type UnderlineTabsTriggerProps = ReadableBoxedValues<{
+ value: string;
+ onmouseenter: TabsPrimitive.TriggerProps['onmouseenter'];
+ onmouseleave: TabsPrimitive.TriggerProps['onmouseleave'];
+ onfocus: TabsPrimitive.TriggerProps['onfocus'];
+ onblur: TabsPrimitive.TriggerProps['onblur'];
+}>;
+
+class UnderlineTabsTriggerState {
+ constructor(
+ readonly opts: UnderlineTabsTriggerProps,
+ readonly rootState: UnderlineTabsRootState
+ ) {}
+
+ handleFocus() {
+ this.rootState.isHovered = true;
+ this.rootState.hoveredTab = this.opts.value.current;
+ }
+
+ handleBlur() {
+ if (this.rootState.hoveredTab === this.opts.value.current) {
+ this.rootState.isHovered = false;
+ }
+ }
+
+ onmouseenter(e: Parameters>[0]) {
+ this.handleFocus();
+ this.opts.onmouseenter.current?.(e);
+ }
+
+ onmouseleave(e: Parameters>[0]) {
+ this.handleBlur();
+ this.opts.onmouseleave.current?.(e);
+ }
+
+ onfocus(e: Parameters>[0]) {
+ this.handleFocus();
+ this.opts.onfocus.current?.(e);
+ }
+
+ onblur(e: Parameters>[0]) {
+ this.handleBlur();
+
+ this.opts.onblur.current?.(e);
+ }
+
+ props = $derived.by(() => ({
+ value: this.opts.value.current,
+ onmouseenter: this.onmouseenter.bind(this),
+ onmouseleave: this.onmouseleave.bind(this),
+ onfocus: this.onfocus.bind(this),
+ onblur: this.onblur.bind(this)
+ }));
+}
+
+const ctx = new Context('underline-tabs-root-ctx');
+
+export function useUnderlineTabs(opts: UnderlineTabsRootProps) {
+ return ctx.set(new UnderlineTabsRootState(opts));
+}
+
+export function useUnderlineTabsTrigger(opts: UnderlineTabsTriggerProps) {
+ return new UnderlineTabsTriggerState(opts, ctx.get());
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index cab08ef8..34138128 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -49,5 +49,6 @@ export { BarChart, LineChart, AreaChart, PieChart, scaleBand } from './base/char
export * from './tools';
+export * as UnderlineTabs from './custom/underline-tabs';
export * from './custom/editor/ui';
export * as ImageCropper from './custom/image-cropper';