Skip to content
Product block

Saved View Tabs

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.

306 lineslucide-reactAdded 27 Aug 2026Updated 2 Sept 2026
  • saved views
  • tabs
  • filters
  • dashboard
  • sharing

What's included

  • components/dashboard-saved-views.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add dashboard-saved-views

Or over MCP, from your editor's agent — no account needed.

License

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.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Support queue

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.

Filters in this view

  • Status is open
  • Any assignee
  • Last 30 days
  • Enterprise only

Narrow it further

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/dashboard-saved-views.tsx
'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&apos;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>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/dashboard-saved-views.tsx and import it where you need the section:

import { DashboardSavedViews } from '@/components/dashboard-saved-views'

Props

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.

PropTypeDefault
viewsSavedView[]DEFAULT_VIEWS
classNamestring''

Not using React?

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.

dashboard-saved-views.html
<!--
  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&#x27;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>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

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.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

8 more blocks in Dashboards

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 Dashboards

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Dashboards blocks

View category
Open the full page for this block

KPI Cards With Deltas

Metric row where each card knows whether a rise is good news, so churn going up is red while revenue going up is green.

Dashboards114 lines1 dep
Open the full page for this block

Activity Feed Panel

Recent-events list with typed icons and relative timestamps that keep the absolute value in a <time> element.

Dashboards171 lines1 dep
Open the full page for this block

Kanban Board Columns

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.

Dashboards203 lines1 dep
Open the full page for this block

Audit Log Timeline

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.

Dashboards204 lines1 dep
Open the full page for this block

Comparison Period Picker

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.

Dashboards315 lines1 dep
Open the full page for this block

Alert Rule List

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.

Dashboards282 lines1 dep