Layout

sidebar

Composable, collapsible application sidebar with mobile off-canvas, icon mode, and keyboard toggle.

{{-- The sidebar is a full-height, fixed-position layout primitive, so it cannot
     embed directly inside the docs column. We preview the real sidebar-01 block
     in an iframe; open it standalone for the full experience. --}}
<div class="space-y-3">
    <div class="overflow-hidden rounded-lg border border-border" style="height: 520px">
        <iframe src="{{ route('blocks.preview', 'sidebar-01') }}"
            class="h-full w-full" title="sidebar-01 block"
            loading="lazy"></iframe>
    </div>
    <a href="{{ route('blocks.preview', 'sidebar-01') }}" target="_blank"
        rel="noopener noreferrer"
        class="inline-flex text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground">
        Open the sidebar-01 block full screen &rarr;
    </a>
</div>

Installation

php artisan ui:add sidebar

1. Install dependencies

  • composer: gehrisandro/tailwind-merge-laravel
  • npm: alpinejs
  • npm: @alpinejs/focus

2. Copy each file into resources/views/components/ui/

resources/views/components/ui/sidebar/provider.blade.php

@props([
    'open' => true,
])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
        $attributes->get('class'),
    );
@endphp

<div x-data="{
    open: @js((bool) $open),
    openMobile: false,
    isMobile: false,
    get state() { return this.open ? 'expanded' : 'collapsed' },
    toggleSidebar() {
        if (this.isMobile) { this.openMobile = !this.openMobile; }
        else { this.open = !this.open; }
    },
    init() {
        const mq = window.matchMedia('(max-width: 767px)');
        this.isMobile = mq.matches;
        mq.addEventListener('change', e => { this.isMobile = e.matches });
        this.$watch('open', v => {
            document.cookie = `sidebar_state=${v}; path=/; max-age=604800`;
        });
        window.addEventListener('keydown', e => {
            if (e.key === 'b' && (e.metaKey || e.ctrlKey)) {
                e.preventDefault();
                this.toggleSidebar();
            }
        });
    },
}"
    style="--sidebar-width: 16rem; --sidebar-width-icon: 3rem; --sidebar-width-mobile: 18rem;"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</div>

resources/views/components/ui/sidebar/index.blade.php

@props([
    'side' => 'left',
    'collapsible' => 'icon',
])

@php
    $isIcon = $collapsible === 'icon';
    $offscreen = $side === 'right' ? 'translate-x-full' : '-translate-x-full';

    // Layout gap the sidebar reserves: full → icon width → 0 (offcanvas).
    $gapWidth =
        "state === 'expanded' ? 'var(--sidebar-width)' : " .
        ($isIcon ? "'var(--sidebar-width-icon)'" : "'0px'");

    // The fixed panel: icon mode shrinks its width; offcanvas keeps full width
    // and slides off-screen (so icon-mode tooltips are never clipped).
    $panelWidth = $isIcon
        ? "state === 'expanded' ? 'var(--sidebar-width)' : 'var(--sidebar-width-icon)'"
        : "'var(--sidebar-width)'";
    $panelSlide = $isIcon
        ? "''"
        : "state === 'collapsed' ? '{$offscreen}' : ''";
@endphp

{{-- Desktop sidebar --}}
<div class="group peer hidden text-sidebar-foreground md:block"
    x-bind:data-state="state"
    x-bind:data-collapsible="state === 'collapsed' ? '{{ $collapsible }}' : ''"
    data-side="{{ $side }}" data-variant="sidebar">
    {{-- gap handler --}}
    <div class="relative bg-transparent transition-[width] duration-200 ease-linear"
        x-bind:style="`width: ${ {{ $gapWidth }} }`"></div>
    {{-- fixed panel --}}
    <div class="fixed inset-y-0 z-10 hidden h-svh transition-[left,right,width,transform] duration-200 ease-linear md:flex {{ $side === 'right' ? 'right-0' : 'left-0' }}"
        x-bind:style="`width: ${ {{ $panelWidth }} }`"
        x-bind:class="{{ $panelSlide }}">
        <div data-sidebar="sidebar"
            class="flex h-full w-full flex-col bg-sidebar {{ $side === 'right' ? 'border-l' : 'border-r' }} border-sidebar-border">
            {{ $slot }}
        </div>
    </div>
</div>

{{-- Mobile sidebar (off-canvas sheet) --}}
<template x-if="isMobile">
    <div x-show="openMobile" x-cloak @keydown.escape.window="openMobile = false">
        <div class="fixed inset-0 z-50 bg-black/50" x-show="openMobile"
            x-transition.opacity @click="openMobile = false"></div>
        <div class="fixed inset-y-0 z-50 flex h-svh w-(--sidebar-width-mobile) flex-col bg-sidebar text-sidebar-foreground {{ $side === 'right' ? 'right-0' : 'left-0' }}"
            role="dialog" aria-modal="true" data-sidebar="sidebar" data-mobile="true"
            x-show="openMobile" x-trap.noscroll.inert="openMobile"
            x-transition:enter="transition ease-in-out duration-300"
            x-transition:enter-start="{{ $offscreen }}"
            x-transition:enter-end="translate-x-0"
            x-transition:leave="transition ease-in-out duration-300"
            x-transition:leave-start="translate-x-0"
            x-transition:leave-end="{{ $offscreen }}">
            <div class="flex h-full w-full flex-col">
                {{ $slot }}
            </div>
        </div>
    </div>
</template>

resources/views/components/ui/sidebar/trigger.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'inline-flex size-7 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
        $attributes->get('class'),
    );
@endphp

<button type="button" aria-label="Toggle Sidebar" @click="toggleSidebar()"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
        stroke="currentColor" stroke-width="2" stroke-linecap="round"
        stroke-linejoin="round" aria-hidden="true">
        <rect width="18" height="18" x="3" y="3" rx="2" />
        <path d="M9 3v18" />
    </svg>
    <span class="sr-only">Toggle Sidebar</span>
</button>

resources/views/components/ui/sidebar/rail.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 cursor-w-resize transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
        $attributes->get('class'),
    );
@endphp

<button type="button" aria-label="Toggle Sidebar" tabindex="-1"
    title="Toggle Sidebar" @click="toggleSidebar()"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}></button>

resources/views/components/ui/sidebar/inset.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'relative flex min-h-svh flex-1 flex-col bg-background',
        $attributes->get('class'),
    );
@endphp

<main {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</main>

resources/views/components/ui/sidebar/header.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'flex flex-col gap-2 p-2',
        $attributes->get('class'),
    );
@endphp

<div data-sidebar="header"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</div>

resources/views/components/ui/sidebar/footer.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'flex flex-col gap-2 p-2',
        $attributes->get('class'),
    );
@endphp

<div data-sidebar="footer"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</div>

resources/views/components/ui/sidebar/content.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
        $attributes->get('class'),
    );
@endphp

<div data-sidebar="content"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</div>

resources/views/components/ui/sidebar/group.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'relative flex w-full min-w-0 flex-col p-2',
        $attributes->get('class'),
    );
@endphp

<div data-sidebar="group"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</div>

resources/views/components/ui/sidebar/group-label.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
        $attributes->get('class'),
    );
@endphp

<div data-sidebar="group-label"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</div>

resources/views/components/ui/sidebar/group-content.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'w-full text-sm',
        $attributes->get('class'),
    );
@endphp

<div data-sidebar="group-content"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</div>

resources/views/components/ui/sidebar/group-action.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=icon]:hidden',
        $attributes->get('class'),
    );
@endphp

<button type="button" data-sidebar="group-action"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</button>

resources/views/components/ui/sidebar/menu.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'flex w-full min-w-0 flex-col gap-1',
        $attributes->get('class'),
    );
@endphp

<ul data-sidebar="menu"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</ul>

resources/views/components/ui/sidebar/menu-item.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'group/menu-item relative',
        $attributes->get('class'),
    );
@endphp

<li data-sidebar="menu-item"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</li>

resources/views/components/ui/sidebar/menu-button.blade.php

@props([
    'variant' => 'default',
    'size' => 'default',
    'isActive' => false,
    'href' => null,
    'tooltip' => null,
])

@php
    $base =
        'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0';

    $variants = match ($variant) {
        'outline'
            => 'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]',
        default => '',
    };

    $sizes = match ($size) {
        'sm' => 'h-7 text-xs',
        'lg' => 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
        default => 'h-8 text-sm',
    };

    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        $base,
        $variants,
        $sizes,
        $attributes->get('class'),
    );

    $tag = $href ? 'a' : 'button';
@endphp

{{-- The button and tooltip stay SIBLINGS (no wrapper) so menu-badge/menu-action
     keep their `peer/menu-button` relationship. The tooltip is pure CSS: shown
     only when the sidebar is collapsed to icons and the button is hovered. --}}
<{{ $tag }} data-sidebar="menu-button" data-size="{{ $size }}"
    data-active="{{ $isActive ? 'true' : 'false' }}"
    @if ($href) href="{{ $href }}" @else type="button" @endif
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</{{ $tag }}>
@if ($tooltip)
    <span role="tooltip"
        class="pointer-events-none absolute left-full top-1/2 z-50 ml-2 hidden w-fit -translate-y-1/2 whitespace-nowrap rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground group-data-[collapsible=icon]:peer-hover/menu-button:block">
        {{ $tooltip }}
    </span>
@endif

resources/views/components/ui/sidebar/menu-action.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=icon]:hidden',
        $attributes->get('class'),
    );
@endphp

<button type="button" data-sidebar="menu-action"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</button>

resources/views/components/ui/sidebar/menu-badge.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 group-data-[collapsible=icon]:hidden',
        $attributes->get('class'),
    );
@endphp

<div data-sidebar="menu-badge"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</div>

resources/views/components/ui/sidebar/menu-skeleton.blade.php

@props([
    'showIcon' => false,
])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'flex h-8 items-center gap-2 rounded-md px-2',
        $attributes->get('class'),
    );
@endphp

<div data-sidebar="menu-skeleton"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    @if ($showIcon)
        <div class="size-4 shrink-0 animate-pulse rounded-md bg-accent"
            data-sidebar="menu-skeleton-icon"></div>
    @endif
    <div class="h-4 max-w-(--skeleton-width) flex-1 animate-pulse rounded-md bg-accent"
        data-sidebar="menu-skeleton-text"
        style="--skeleton-width: {{ random_int(50, 90) }}%"></div>
</div>

resources/views/components/ui/sidebar/menu-sub.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden',
        $attributes->get('class'),
    );
@endphp

<ul data-sidebar="menu-sub"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</ul>

resources/views/components/ui/sidebar/menu-sub-item.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'group/menu-sub-item relative',
        $attributes->get('class'),
    );
@endphp

<li data-sidebar="menu-sub-item"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</li>

resources/views/components/ui/sidebar/menu-sub-button.blade.php

@props([
    'size' => 'md',
    'isActive' => false,
    'href' => null,
])

@php
    $sizes = match ($size) {
        'sm' => 'text-xs',
        default => 'text-sm',
    };

    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden',
        $sizes,
        $attributes->get('class'),
    );

    $tag = $href ? 'a' : 'button';
@endphp

<{{ $tag }} data-sidebar="menu-sub-button" data-size="{{ $size }}"
    data-active="{{ $isActive ? 'true' : 'false' }}"
    @if ($href) href="{{ $href }}" @else type="button" @endif
    {{ $attributes->except('class')->merge(['class' => $classes]) }}>
    {{ $slot }}
</{{ $tag }}>

resources/views/components/ui/sidebar/separator.blade.php

@props([])

@php
    $classes = \TailwindMerge\Laravel\Facades\TailwindMerge::merge(
        'mx-2 h-px w-auto shrink-0 bg-sidebar-border',
        $attributes->get('class'),
    );
@endphp

<div role="separator" aria-orientation="horizontal" data-sidebar="separator"
    {{ $attributes->except('class')->merge(['class' => $classes]) }}></div>