Skip to content
KONIGI Patterns

Dashboards / Interaction / Saved view

‹ 5 of 8 ›

Saved view

People need to return to useful settings without recreating them each time.

Updated September 10, 2026

Problem

People repeatedly rebuild the same filters, time range, and display settings before they can start work.

Solution

Let people name and save a useful configuration. Make clear which settings are included, such as filters, sort order, and visible columns.

Distinguish a relative range that moves with time from a fixed historical period. Show that choice when saving and reopening the view.

Separate personal views from shared views and defaults. Give shared views an owner, and report saved settings that no longer refer to valid data or panels.

Use when

People often return to a configuration that takes several steps to recreate.

Don’t use when

The state is trivially reconstructed, or the URL already carries everything. If a link restores the view completely, a bookmark is a saved view and the product does not need its own feature.

Trade-offs

Saved views accumulate and can preserve outdated assumptions. Shared views need maintenance. Different personal filters can make colleagues think they are discussing the same dashboard when they are seeing different results.

Checklist

  • What exactly is captured—filters, range, sort, columns, expansion?
  • Is an absolute time range saved, and should it be?
  • Can a view be personal, shared or default, and is the difference obvious?
  • Who owns a shared view, and what happens when they leave?
  • What does a view referencing a dimension that no longer exists do?
  • Is a stale view distinguishable from a legitimately empty result?
  • Can views be found and searched once there are hundreds?
  • Is there a way to see what a view actually captured before opening it?
  • Does the URL already do this, making the feature redundant?
  • Is anything pruning views nobody has opened in a year?

Compare

Grafana splits the job across saved dashboards, starred dashboards and variable state carried in the URL, so “saved view” is assembled from three mechanisms rather than existing as one object, and the absolute-versus-relative range trap sits squarely in the middle of it. Sentry makes saved searches first-class with personal and team scoping, which fits a triage product where different roles genuinely need different queues. Honeycomb saves queries and collects them into boards, so the saved unit is a question rather than a page state, which ages better because a question stays meaningful when the data changes. Datadog leans on saved views across several products with team sharing, and shows the accumulation problem clearly at scale.

Filter bar supplies many of the saved choices. Template variable applies shared selections. Time-range picker defines whether the window moves or stays fixed.

Share and embed sends a view to others. Multi-page dashboard fits stable views that deserve their own pages.

Saved view anatomy A named view listing everything it captured: filters, sort, columns and a relative range, with the absolute range marked as the thing not to keep. Beside it the three ownership tiers, and below, a view whose filter points at a service that no longer exists. What a saved view actually holds Morning triage filters env=prod, sev>=2 sort last seen, desc columns 7 of 14 expanded rows yes range last 24h absolute range not kept 1 Who owns it Personal Cheap, safe, proliferate quietly. Shared Organisational furniture. Needs a name against it. Default What everyone sees first. 2 filters service=legacy-cart no longer exists · 0 rows 3 1 CAPTURE THE RELATIVE ONE A view saved during an incident, with its absolute range, reopens next month still showing that incident. 2 THE DEFAULT IS POLITICAL Whoever controls the state a page opens in controls what everybody sees first. 3 DRIFT A view referencing a service that no longer exists should say so. Returning nothing is indistinguishable from a healthy quiet system.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

The same five filters, every morning, as one click. The part teams get wrong is the unsaved-changes state: a view that silently drifts from what its name says stops being trustworthy within a week.

shadcn
npx shadcn@latest add button toggle-group
Tokens
--card--card-foreground--foreground--muted-foreground--border--status-warn

Morning triage

filters
env=prod, sev>=2
sort
last seen, desc
columns
7 of 14
expanded rows
yes
range
last 24h
absolute range
not kept

Who owns it

Owned by Priya.

Legacy cart errors

filters
service=legacy-cart
sort
count, desc
columns
3 of 14
expanded rows
no
range
last 7d

service=legacy-cart no longer exists · 0 rows

Who owns it

Only you see it.

SavedViewCard.tsxThe view type has no field for an absolute range. The card lists what it captured, checks each filter still resolves, and holds the ownership tier as a control.

import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";

/**
 * What a saved view holds, shown before it is opened.
 *
 * The type is the decision about what gets captured. Filters, sort, columns,
 * expansion and a relative range are in it. An absolute range is not, and
 * there is no field to put one in: a view saved during an incident with its
 * absolute range reopens next month still showing that incident. The card
 * says "not kept" on the page so the person opening it knows what it will
 * not restore.
 */
export type Scope = "personal" | "shared" | "default";

export type SavedView = {
  name: string;
  scope: Scope;
  /** A shared or default view needs a name against it. */
  owner: string;
  /** Dimension to value, in the operator syntax the page uses: { sev: ">=2" }. */
  filters: Record<string, string>;
  sort: { by: string; dir: "asc" | "desc" };
  columns: { shown: string[]; of: number };
  expandedRows: boolean;
  /** Relative only. "last 24h" reopens as the last 24 hours from whenever it is opened. */
  range: `last ${string}`;
};

const SCOPES: Record<Scope, { label: string; hint: (v: SavedView) => string }> = {
  personal: { label: "Personal", hint: () => "Only you see it." },
  shared: { label: "Shared", hint: (v) => `Owned by ${v.owner}.` },
  default: { label: "Default", hint: () => "What everyone sees first." },
};

type Props = {
  view: SavedView;
  /** Whether a filter's dimension value still exists in the source. A view
   *  pointed at a service that was deleted should say so; returning nothing
   *  is indistinguishable from a healthy quiet system. */
  exists: (dimension: string, value: string) => boolean;
  /** What the view returns right now. */
  rows: number;
  onOpen: (view: SavedView) => void;
  onScopeChange: (scope: Scope) => void;
};

export function SavedViewCard({ view, exists, rows, onOpen, onScopeChange }: Props) {
  const missing = Object.entries(view.filters).filter(([k, v]) => !exists(k, v));
  const drifted = missing.length > 0;
  const filters = Object.entries(view.filters).map(([k, v]) => `${k}${/^[<>=!]/.test(v) ? v : `=${v}`}`).join(", ");

  const row = (label: string, value: string, flag = false) => (
    <div className="contents">
      <dt className={cn("text-muted-foreground", flag && "text-status-warn")}>{label}</dt>
      <dd className={cn("m-0 text-right text-card-foreground", flag && "text-status-warn")}>{value}</dd>
    </div>
  );

  return (
    <div className={cn("rounded-lg border bg-card p-4", drifted && "border-status-warn")}>
      <div className="flex items-center justify-between border-b pb-2">
        <p className="text-sm text-card-foreground">{view.name}</p>
        <Button size="sm" variant="outline" className="h-6 px-2.5 text-xs" onClick={() => onOpen(view)}>open</Button>
      </div>

      <dl className="mt-3 grid grid-cols-[100px_1fr] gap-x-3 gap-y-2 text-xs">
        {row("filters", filters, drifted)}
        {row("sort", `${view.sort.by}, ${view.sort.dir}`)}
        {row("columns", `${view.columns.shown.length} of ${view.columns.of}`)}
        {row("expanded rows", view.expandedRows ? "yes" : "no")}
        {row("range", view.range)}
      </dl>
      {drifted ? (
        <p className="mt-2 text-[11px] text-status-warn">
          {missing.map(([k, v]) => `${k}=${v}`).join(", ")} no longer exists · {rows} rows
        </p>
      ) : (
        <dl className="mt-2 grid grid-cols-[100px_1fr] gap-x-3 border-t border-dashed pt-2 text-xs">
          {row("absolute range", "not kept", true)}
        </dl>
      )}

      <div className="mt-3 border-t pt-3">
        <p className="text-[11px] text-muted-foreground">Who owns it</p>
        <ToggleGroup
          type="single"
          value={view.scope}
          onValueChange={(s) => s && onScopeChange(s as Scope)}
          aria-label="Who owns it"
          className="mt-1.5 justify-start gap-1"
        >
          {(Object.keys(SCOPES) as Scope[]).map((s) => (
            <ToggleGroupItem key={s} value={s} className="h-6 rounded-md border px-2 text-[11px] data-[state=on]:border-foreground">
              {SCOPES[s].label}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
        <p className="mt-1.5 text-[11px] text-muted-foreground">{SCOPES[view.scope].hint(view)}</p>
      </div>
    </div>
  );
}

demo.tsxHow it is called: a shared morning view that resolves, and a personal one pointed at a service that no longer exists.

import { useState } from "react";
import { SavedViewCard, type SavedView, type Scope } from "./SavedViewCard";

/**
 * Two views. Morning triage is a shared view that still resolves. The second
 * filters on a service that was decommissioned, and says so instead of
 * quietly returning an empty table.
 */
const SERVICES = ["cart", "checkout", "search", "auth"];
const exists = (dimension: string, value: string) => dimension !== "service" || SERVICES.includes(value);

const MORNING: SavedView = {
  name: "Morning triage",
  scope: "shared",
  owner: "Priya",
  filters: { env: "prod", sev: ">=2" },
  sort: { by: "last seen", dir: "desc" },
  columns: { shown: ["title", "service", "sev", "count", "first seen", "last seen", "owner"], of: 14 },
  expandedRows: true,
  range: "last 24h",
};

const LEGACY: SavedView = {
  name: "Legacy cart errors",
  scope: "personal",
  owner: "Priya",
  filters: { service: "legacy-cart" },
  sort: { by: "count", dir: "desc" },
  columns: { shown: ["title", "count", "last seen"], of: 14 },
  expandedRows: false,
  range: "last 7d",
};

export default function Demo() {
  const [scope, setScope] = useState<Scope>(MORNING.scope);
  const open = (v: SavedView) => { location.hash = `#view/${encodeURIComponent(v.name)}`; };

  return (
    <div className="flex w-[360px] flex-col gap-4">
      <SavedViewCard view={{ ...MORNING, scope }} exists={exists} rows={38} onOpen={open} onScopeChange={setScope} />
      <SavedViewCard view={LEGACY} exists={exists} rows={0} onOpen={open} onScopeChange={() => {}} />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No screenshots yet for this pattern. Browse Products to see other examples.