Sidebar Dashboard Shell
Sidebar, top bar and scrollable content slot, with a mobile drawer that closes on Escape and marks the active route.
Saved views with the third state most implementations drop: a shared view with unsaved edits, where Save changes what a colleague sees tomorrow. Two honest buttons instead of one, ownership on the tab, and dirty said in words.
components/dashboard-saved-views.tsxnpx hoverlab add dashboard-saved-views
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
One filter has been added to this view: Enterprise only
All open is shared with your team. Saving changes what everyone sees, including tomorrow's scheduled digest.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <DashboardSavedViews> — the tab strip a dashboard grows into.
*
* Dashboards had the shell, the KPI cards, the activity feed, the page
* header, the kanban, the audit timeline and the calendar. Every one of
* them shows a single, fixed slice of the data. The moment two people
* share a dashboard they stop agreeing about which slice, and the product
* either grows saved views or grows a screenshot habit.
*
* THE ONE THING THIS GETS RIGHT THAT MOST DO NOT
*
* A saved view has three states, and most implementations render two.
* There is the view as saved, the view with unsaved edits, and — the one
* that gets skipped — a *shared* view with unsaved edits, where pressing
* Save changes what a colleague sees tomorrow morning. That case gets its
* own sentence and a second button, because "Save" and "Save as a copy"
* are the two honest answers and defaulting to the first is how one
* person quietly redefines everyone else's morning report.
*
* DIRTY IS SHOWN, NOT IMPLIED
*
* A dot beside a tab name means nothing to anyone who has not been taught
* it. The strip says "Edited" in words and the bar underneath names what
* changed, so the choice to save or discard can be made without pressing
* either to find out.
*
* PERSONAL VIEWS SAY SO
*
* Ownership is on the tab, not in a settings drawer. Whether a change is
* private or public is the single most important fact about a saved view
* and it is invisible in most products until someone complains.
*
* ACCESSIBILITY: a real tablist with roving `aria-selected`, arrow-key
* movement between tabs, and a `aria-live="polite"` status line for the
* dirty state. The filter chips are buttons, not divs, so the thing that
* makes a view dirty is reachable from a keyboard.
*/
import * as React from 'react'
import { Check, Lock, RotateCcw, Save, Users } from 'lucide-react'
export interface SavedView {
id: string
name: string
/** Shared views are the ones where Save has a blast radius. */
shared?: boolean
/** The filters this view is saved with, as human phrases. */
filters: string[]
}
export interface DashboardSavedViewsProps {
views?: SavedView[]
className?: string
}
const DEFAULT_VIEWS: SavedView[] = [
{
id: 'all-open',
name: 'All open',
shared: true,
filters: ['Status is open', 'Any assignee', 'Last 30 days'],
},
{
id: 'my-escalations',
name: 'My escalations',
filters: ['Assigned to me', 'Priority is urgent', 'Breached SLA'],
},
{
id: 'weekly-review',
name: 'Weekly review',
shared: true,
filters: ['Closed last week', 'Any priority', 'Grouped by team'],
},
]
/** Chips a visitor can toggle to make the current view dirty. */
const EXTRA_FILTERS = ['Enterprise only', 'Has attachment', 'Reopened once or more']
export function DashboardSavedViews({
views = DEFAULT_VIEWS,
className = '',
}: DashboardSavedViewsProps) {
const [activeId, setActiveId] = React.useState(views[0]?.id ?? '')
/*
* Opens dirty, on a shared view.
*
* Clean is the boring half. Everything this component has to say — the
* two honest save buttons, the sentence about tomorrow's digest — only
* exists once a shared view has unsaved edits, so that is where the
* demo starts. Press "Reset to saved" to see the quiet state.
*/
const [extra, setExtra] = React.useState<string[]>(['Enterprise only'])
const [saved, setSaved] = React.useState('')
const tabsRef = React.useRef<Array<HTMLButtonElement | null>>([])
const active = views.find((v) => v.id === activeId) ?? views[0]
const dirty = extra.length > 0
function select(id: string) {
setActiveId(id)
setExtra([])
setSaved('')
}
/* Arrow keys move between tabs — a tablist that only takes clicks is a
row of buttons wearing a role it does not honour. */
function onTabKey(event: React.KeyboardEvent, index: number) {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return
event.preventDefault()
const next =
event.key === 'ArrowRight'
? (index + 1) % views.length
: (index - 1 + views.length) % views.length
select(views[next]!.id)
tabsRef.current[next]?.focus()
}
if (!active) return null
return (
<section className={`mx-auto w-full max-w-5xl px-4 py-16 sm:px-6 ${className}`}>
<div className="overflow-hidden rounded-2xl border border-border bg-card">
<header className="border-b border-border px-5 pt-4">
<h2 className="text-base font-semibold text-foreground">Support queue</h2>
<div
role="tablist"
aria-label="Saved views"
className="mt-3 flex flex-wrap items-center gap-1"
>
{views.map((view, index) => {
const selected = view.id === activeId
return (
<button
key={view.id}
ref={(node) => {
tabsRef.current[index] = node
}}
role="tab"
type="button"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
onKeyDown={(e) => onTabKey(e, index)}
onClick={() => select(view.id)}
className={`inline-flex items-center gap-1.5 rounded-t-lg border-b-2 px-3 py-2 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
selected
? 'border-primary font-semibold text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{/* Ownership on the tab. It decides what Save means. */}
{view.shared ? (
<Users aria-hidden className="h-3.5 w-3.5" />
) : (
<Lock aria-hidden className="h-3.5 w-3.5" />
)}
{view.name}
<span className="sr-only">
{view.shared ? ' (shared with the team)' : ' (only you)'}
</span>
{selected && dirty ? (
<span className="ms-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-400">
Edited
</span>
) : null}
</button>
)
})}
</div>
</header>
{/*
The bar that names the change. Live, because it appears as a
result of pressing a chip somewhere else on the screen.
*/}
<div aria-live="polite">
{dirty ? (
<div className="border-b border-border bg-muted/50 px-5 py-3">
<p className="text-sm text-foreground">
{extra.length === 1
? 'One filter has been added to this view: '
: `${extra.length} filters have been added to this view: `}
<span className="font-medium">{extra.join(', ')}</span>
</p>
{/* The case everyone else collapses into a single Save. */}
{active.shared ? (
<p className="mt-1 text-xs text-muted-foreground">
<strong className="font-semibold text-foreground">
{active.name} is shared with your team.
</strong>{' '}
Saving changes what everyone sees, including tomorrow's
scheduled digest.
</p>
) : (
<p className="mt-1 text-xs text-muted-foreground">
{active.name} is visible only to you.
</p>
)}
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
setSaved(`Saved to ${active.name}`)
setExtra([])
}}
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-3 text-xs font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Save aria-hidden className="h-3.5 w-3.5" />
{active.shared ? `Save for everyone` : 'Save'}
</button>
<button
type="button"
onClick={() => {
setSaved(`Saved as “${active.name} (copy)” — only you can see it`)
setExtra([])
}}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-xs font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Lock aria-hidden className="h-3.5 w-3.5" />
Save as my own copy
</button>
<button
type="button"
onClick={() => setExtra([])}
className="inline-flex h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw aria-hidden className="h-3.5 w-3.5" />
Reset to saved
</button>
</div>
</div>
) : saved ? (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-5 py-2.5 text-sm text-foreground">
<Check aria-hidden className="h-4 w-4 text-primary" />
{saved}
</div>
) : null}
</div>
<div className="px-5 py-5">
<h3
data-stress-ignore
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Filters in this view
</h3>
<ul className="mt-2 flex flex-wrap gap-2">
{active.filters.map((filter) => (
<li
key={filter}
className="inline-flex items-center rounded-full border border-border bg-muted/40 px-3 py-1 text-xs text-foreground"
>
{filter}
</li>
))}
{extra.map((filter) => (
<li
key={filter}
className="inline-flex items-center rounded-full border border-primary/40 bg-primary/10 px-3 py-1 text-xs font-medium text-primary"
>
{filter}
</li>
))}
</ul>
<h3
data-stress-ignore
className="mt-6 text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Narrow it further
</h3>
<ul className="mt-2 flex flex-wrap gap-2">
{EXTRA_FILTERS.map((filter) => {
const on = extra.includes(filter)
return (
<li key={filter}>
<button
type="button"
aria-pressed={on}
onClick={() =>
setExtra((e) =>
e.includes(filter) ? e.filter((f) => f !== filter) : [...e, filter],
)
}
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
on
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-muted-foreground hover:text-foreground'
}`}
>
{filter}
</button>
</li>
)
})}
</ul>
</div>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/dashboard-saved-views.tsx and import it where you need the section:
import { DashboardSavedViews } from '@/components/dashboard-saved-views'Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
views | SavedView[] | DEFAULT_VIEWS |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Saved View Tabs — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section class="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6 ">
<div class="overflow-hidden rounded-2xl border border-border bg-card">
<header class="border-b border-border px-5 pt-4">
<h2 class="text-base font-semibold text-foreground">Support queue</h2>
<div role="tablist" aria-label="Saved views" class="mt-3 flex flex-wrap items-center gap-1">
<button role="tab" type="button" aria-selected="true" tabindex="0" class="inline-flex items-center gap-1.5 rounded-t-lg border-b-2 px-3 py-2 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-primary font-semibold text-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users h-3.5 w-3.5" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><path d="M16 3.128a4 4 0 0 1 0 7.744"></path><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><circle cx="9" cy="7" r="4"></circle></svg>All open<span class="sr-only"> (shared with the team)</span><span class="ms-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-400">Edited</span></button>
<button role="tab" type="button" aria-selected="false" tabindex="-1" class="inline-flex items-center gap-1.5 rounded-t-lg border-b-2 px-3 py-2 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-transparent text-muted-foreground hover:text-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-lock h-3.5 w-3.5" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>My escalations<span class="sr-only"> (only you)</span></button>
<button role="tab" type="button" aria-selected="false" tabindex="-1" class="inline-flex items-center gap-1.5 rounded-t-lg border-b-2 px-3 py-2 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-transparent text-muted-foreground hover:text-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users h-3.5 w-3.5" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><path d="M16 3.128a4 4 0 0 1 0 7.744"></path><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><circle cx="9" cy="7" r="4"></circle></svg>Weekly review<span class="sr-only"> (shared with the team)</span></button>
</div>
</header>
<div aria-live="polite">
<div class="border-b border-border bg-muted/50 px-5 py-3">
<p class="text-sm text-foreground">One filter has been added to this view: <span class="font-medium">Enterprise only</span></p>
<p class="mt-1 text-xs text-muted-foreground"><strong class="font-semibold text-foreground">All open is shared with your team.</strong> Saving changes what everyone sees, including tomorrow's scheduled digest.</p>
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-3 text-xs font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-save h-3.5 w-3.5" aria-hidden="true"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"></path><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"></path><path d="M7 3v4a1 1 0 0 0 1 1h7"></path></svg>Save for everyone</button>
<button type="button" class="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-xs font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-lock h-3.5 w-3.5" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>Save as my own copy</button>
<button type="button" class="inline-flex h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw h-3.5 w-3.5" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg>Reset to saved</button>
</div>
</div>
</div>
<div class="px-5 py-5">
<h3 data-stress-ignore="true" class="text-xs font-medium uppercase tracking-wide text-muted-foreground">Filters in this view</h3>
<ul class="mt-2 flex flex-wrap gap-2">
<li class="inline-flex items-center rounded-full border border-border bg-muted/40 px-3 py-1 text-xs text-foreground">Status is open</li>
<li class="inline-flex items-center rounded-full border border-border bg-muted/40 px-3 py-1 text-xs text-foreground">Any assignee</li>
<li class="inline-flex items-center rounded-full border border-border bg-muted/40 px-3 py-1 text-xs text-foreground">Last 30 days</li>
<li class="inline-flex items-center rounded-full border border-primary/40 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">Enterprise only</li>
</ul>
<h3 data-stress-ignore="true" class="mt-6 text-xs font-medium uppercase tracking-wide text-muted-foreground">Narrow it further</h3>
<ul class="mt-2 flex flex-wrap gap-2">
<li>
<button type="button" aria-pressed="true" class="inline-flex items-center rounded-full border px-3 py-1 text-xs transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background border-primary bg-primary text-primary-foreground">Enterprise only</button>
</li>
<li>
<button type="button" aria-pressed="false" class="inline-flex items-center rounded-full border px-3 py-1 text-xs transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background border-border bg-background text-muted-foreground hover:text-foreground">Has attachment</button>
</li>
<li>
<button type="button" aria-pressed="false" class="inline-flex items-center rounded-full border px-3 py-1 text-xs transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background border-border bg-background text-muted-foreground hover:text-foreground">Reopened once or more</button>
</li>
</ul>
</div>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse DashboardsCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
Sidebar, top bar and scrollable content slot, with a mobile drawer that closes on Escape and marks the active route.
Metric row where each card knows whether a rise is good news, so churn going up is red while revenue going up is green.
Recent-events list with typed icons and relative timestamps that keep the absolute value in a <time> element.
Breadcrumb, title, action buttons and a counted tab row that sits flush with the bottom rule.
Four columns of prioritised, tagged, assigned task cards with a blocked state — the board as a layout contract, leaving drag behaviour to your own state and library.
Events grouped by day on a vertical rail, icons varying by type and one entry expanded into a detail card — what keeps forty events scannable instead of forty gray dots.
A reporting window with the comparison window spelled out beside it — resolved dates, UTC arithmetic, and an option to align weekdays so a weekend is never compared against a Tuesday.
Threshold alerts with the two facts most rule lists omit: how often each has fired, and whether its channel is muted — so a rule that is on but delivering nowhere reads as its own state.