Koala logo Design

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.

Quotes 42 Transactions 18 Partners 7
<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>