Tabs
Tab navigation pattern with count pills, skeleton loading, and period filters.
On mobile, tabs render as a native <select> dropdown.
On desktop, tabs use a horizontal bar with Alpine-AJAX for partial page updates.
Mobile tab dropdown
On mobile, tabs are replaced with an Alpine.js dropdown. The trigger button has a border, fills the full width, and shows the active tab name with a chevron. The dropdown panel lists all tabs as links. Resize to mobile to see the dropdown.
Details tab content goes here.
Activity tab content goes here.
Notes tab content goes here.
<!-- Mobile: Alpine dropdown (sm:hidden) -->
<div class="sm:hidden relative" x-data="{ tabOpen: false }">
<button type="button"
x-on:click="tabOpen = !tabOpen"
class="w-full flex items-center justify-between px-3 py-2.5
bg-white dark:bg-gray-700 border border-gray-200
dark:border-gray-600 rounded-xl text-gray-900 dark:text-white">
<span>@activeTabLabel</span>
<svg class="w-5 h-5 ..." :class="tabOpen ? 'rotate-180' : ''">
<path d="m6 9 6 6 6-6"></path>
</svg>
</button>
<div x-show="tabOpen" x-on:click.outside="tabOpen = false"
x-transition:enter="transition ease-out duration-100" ...
class="absolute left-0 right-0 top-full mt-1 bg-white dark:bg-gray-700
border border-gray-200 dark:border-gray-600 rounded-xl
shadow-lg z-10 overflow-hidden">
<ul class="py-1">
<li>
<a href="/route?tab=details"
x-on:click="tabOpen = false; showTabSkeleton(...)"
x-target="container-id"
class="block px-3 py-2 text-brand font-medium">
Details
</a>
</li>
<li>
<a href="/route?tab=activity"
x-on:click="tabOpen = false; showTabSkeleton(...)"
x-target="container-id"
class="block px-3 py-2 text-gray-700 hover:bg-gray-100">
Activity (12)
</a>
</li>
</ul>
</div>
</div>
<!-- Desktop: horizontal tab bar (hidden sm:flex) -->
<nav class="hidden sm:flex gap-6 -mb-px border-b ...">
<a href="/route?tab=details"
x-target.push="main"
class="text-brand dark:text-brand-light border-b-2 border-brand ...">
Details
</a>
<a href="/route?tab=activity"
x-target.push="main"
x-on:click="showTabSkeleton('container-id', 'timeline')"
class="text-gray-500 hover:text-gray-700 ...">
Activity <span koala-tab-pill>12</span>
</a>
</nav>
Tab count pill
Use koala-tab-pill for count badges inside tab labels.
The tag helper applies consistent sizing and colours.
<span koala-tab-pill>42</span> <!-- Output classes --> inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300
Skeleton loading
When a tab is clicked, a skeleton placeholder is shown via
showTabSkeleton(tabsId, type)
(defined in _Layout.cshtml).
The active tab triggers the skeleton immediately, and the AJAX response replaces it.
<!-- Trigger skeleton on tab click -->
<a href="/partner/quotes/view/abc?tab=activity"
x-target.push="main"
x-on:click="@(Model.ActiveTab == "activity"
? "$event.preventDefault()"
: "showTabSkeleton('tab-container', 'timeline')")">
Activity
</a>
<!-- Available skeleton types -->
showTabSkeleton('container-id', 'table') // Cards on mobile, header + rows on desktop
showTabSkeleton('container-id', 'form') // 1-col mobile, 2-col label/value on desktop
showTabSkeleton('container-id', 'timeline') // Filter pill + date header + avatar entries
showTabSkeleton('container-id', 'chart') // Vertical bar chart placeholder
showTabSkeleton('container-id', 'notes') // Add button + note cards with avatar + text
showTabSkeleton('container-id', 'users') // Cards on mobile, avatar table rows on desktop
Skeleton type reference
Each skeleton type matches the content the tab displays.
| Type | Used for | Description |
|---|---|---|
| table | Quotes, Transactions, Partners | Cards on mobile (< XL), header + rows on desktop |
| form | Details, Settings | 1-col on mobile, 2-col label/value grid on desktop |
| timeline | Activity | Filter pill + date header + avatar entries |
| chart | Fees | Vertical bar chart placeholder |
| notes | Notes | Add button + note cards with avatar + text |
| users | Team | Cards on mobile (< SM), avatar table rows on desktop |
Period filter buttons
Pill-style buttons for filtering by time period. Default to 30 days. Standard options: 7 days, 30 days, 3 months, 12 months, All time.
<!-- Active state -->
class="bg-gray-900 text-white dark:bg-white dark:text-gray-900
rounded-lg px-3 py-1.5 text-sm font-medium"
<!-- Inactive state -->
class="text-gray-500 hover:bg-gray-100 dark:text-gray-400
dark:hover:bg-gray-700 rounded-lg px-3 py-1.5 text-sm"
<!-- With Alpine-AJAX (server-driven) -->
<a href="/partner/quotes?period=7d"
x-target.push="main"
class="@(Model.Period == "7d"
? "bg-gray-900 text-white dark:bg-white dark:text-gray-900"
: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700")
rounded-lg px-3 py-1.5 text-sm font-medium">
7d
</a>
Active tab click prevention
The currently active tab should prevent navigation by calling
$event.preventDefault().
Inactive tabs trigger a skeleton and navigate via Alpine-AJAX.
<a href="/partner/quotes/view/abc?tab=details"
x-target.push="main"
x-on:click="@(Model.ActiveTab == "details"
? "$event.preventDefault()"
: "showTabSkeleton('tab-container', 'form')")"
class="@(Model.ActiveTab == "details"
? "text-brand dark:text-brand-light border-b-2 border-brand"
: "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200")
pb-3 text-sm font-medium">
Details
</a>