Skip to content
KONIGI

Dashboards / Interaction / Template variable

7 of 8

Template variable

One dashboard layout is right for fifty services, forty hosts, or three environments, and nobody should build it fifty times.

Updated September 9, 2026

Problem

Operational dashboards are templates by nature. The Kubernetes overview is the same page for every cluster. The service page is the same page for every service. Building one per instance means fifty copies that drift; building one page with a dropdown means one thing to maintain and a dashboard the viewer can point at whatever they’re worried about.

Solution

A row of selectors at the top of the dashboard, each bound to a variable that every panel’s query references. Change the selector and every panel re-queries. The selectors are populated from the data itself (a query for distinct service names) so the list is never stale. The chosen values live in the URL, which makes a scoped view a shareable link.

Bach calls this the parameterized screenspace pattern. In products it’s the difference between a dashboard and a dashboard system.

Use when

The same layout applies across many instances of one entity type, and the viewer’s first question is “which one?” Clusters, services, hosts, tenants, regions, environments.

Don’t use when

The instances need different layouts. A database dashboard and a web-server dashboard share a variable bar but not panels; forcing them into one template gives every viewer half a page of empty panels. Also avoid stacking more than four or five variables; past that, the bar becomes a query builder and belongs in a filter bar pattern with saved views.

Trade-offs

Variables make dashboards abstract. A viewer arriving by link sees “cluster: prod-us-east” and has to notice it before trusting anything below. Multi-select variables produce panels with forty series and no legend that fits. Chained variables (pick a cluster, then a namespace, then a pod) are powerful and slow, and the loading state between selections is where dashboards feel broken. And variables invite the anti-pattern of one giant dashboard for everything, which the collapsible-row pattern then has to rescue.

Checklist

  • Is the current selection visible without scrolling, on every screen size?
  • Are variable values in the URL, so the view can be shared and bookmarked?
  • What does the dashboard show while a chained variable is reloading?
  • Is there an “All” option, and does every panel behave sensibly with it?
  • Does the variable list come from data, and how stale can it get?
  • When a variable value no longer exists (the pod was deleted), what does the viewer see?
  • Are panel titles interpolated with the variable so screenshots are self-describing?
  • Can a panel opt out of a variable, and is that visible?

Compare

Grafana is the reference: query, custom, interval, and datasource variables, chaining, multi-select, and the values interpolated into panel titles. Datadog does the same with a lighter bar and adds saved views on top of the variable set, which is the right next step. Netdata inverts it: instead of a variable, the node and room hierarchy in the left rail is the selector, and every chart is already scoped. Honeycomb has no template variables because every view is a query; the “variable” is the WHERE clause, which shows the pattern is a UI over query parameters and can be replaced by one.

Filter bar is what this becomes when the variables are many and arbitrary. Panel grid is what the variables scope. Drill-down is the click that usually sets a variable. Saved view is how teams stop re-selecting the same five values. Overview then detail is the navigation structure variables enable without separate pages.

Template variable anatomy A row of selectors at the top of a dashboard, each populated by its own query against the data, with every panel's query referencing them and the chosen values living in the URL so a scoped view is a link. One layout, fifty services env: prod service: checkout host: all (40) rate($service errors) p95($service, $host) sum by (pod) (up{env="$env"}) 1 2 Service label_values(service) checkout · search · cart · auth · feed · 45 more 3 ?var-env=prod&var- service=checkout 4 1 THE SELECTORS Change one and every panel re-queries. This is the difference between a dashboard and a dashboard system. 2 PANELS REFERENCE THEM Every query names the variable rather than a literal, so the layout is written once. 3 POPULATED FROM THE DATA A query for distinct values, so the list is never stale and never hand-maintained. 4 THE URL CARRIES IT Which makes a scoped view a link somebody can paste into a thread.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

One dashboard parameterised by service, cluster or tenant. The variables belong at the top in reading order, and a chained variable has to reset its children when the parent changes or the page shows a combination that never existed.

shadcn
npx shadcn@latest add select
Tokens
--card--card-foreground--muted--muted-foreground--border--input--status-warn

checkout error rate

rate($service errors)

checkout p95 on all

p95($service, $host)

pods up in prod

sum by (pod) (up{env="$env"})
?var-env=prod&var-service=checkout

TemplateVariables.tsxSelectors populated by their own query, panels that name the variables, children that reset when a parent changes, and the values as a URL.

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";

export type Variable = {
  name: string;
  /** The query that populates it, run against the data, so the list is
   *  never stale and never hand-maintained. */
  query: string;
  /** What that query returned. Absent while it is still running. */
  options?: string[];
  /** A chained variable resets when its parent changes, so the page never
   *  shows a combination that never existed. */
  dependsOn?: string;
  allowAll?: boolean;
};

export type Panel = { title: string; query: string };
export type Values = Record<string, string>;

type Props = {
  variables: Variable[];
  values: Values;
  onChange: (values: Values) => void;
  /** Every panel names the variable, never a literal. The layout is written once. */
  panels: Panel[];
};

export const ALL = "all";

/** $service in a title or a query becomes the chosen value, so a screenshot
 *  says what it is a screenshot of. */
export const interpolate = (text: string, values: Values) =>
  text.replace(/\$(\w+)/g, (_, name) => values[name] ?? `$${name}`);

/** The chosen values as a query string, so a scoped view is a link. */
export const toSearch = (values: Values) =>
  "?" + Object.entries(values).filter(([, v]) => v && v !== ALL).map(([k, v]) => `var-${k}=${encodeURIComponent(v)}`).join("&");

export function TemplateVariables({ variables, values, onChange, panels }: Props) {
  const set = (name: string, value: string) => {
    const next = { ...values, [name]: value };
    const reset = (parent: string) => {
      for (const v of variables) if (v.dependsOn === parent) { next[v.name] = v.allowAll ? ALL : ""; reset(v.name); }
    };
    reset(name);
    onChange(next);
  };

  return (
    <div className="rounded-lg border bg-card p-4">
      {/* In reading order, above everything, on every screen size. */}
      <div className="flex flex-wrap gap-2">
        {variables.map((v) => {
          const value = values[v.name] ?? "";
          const loading = !v.options;
          const gone = !loading && value !== ALL && !v.options!.includes(value);
          const shown = loading ? "loading…" : value === ALL ? `all (${v.options!.length})` : gone ? `${value} (gone)` : value;
          return (
            <Select key={v.name} value={value} onValueChange={(next) => set(v.name, next)} disabled={loading}>
              <SelectTrigger
                className={cn("h-auto w-auto gap-1.5 px-2.5 py-1 text-[11px] shadow-none", gone && "border-status-warn text-status-warn")}
                title={v.query}
                aria-label={v.name}
              >
                <SelectValue>{`${v.name}: ${shown}`}</SelectValue>
              </SelectTrigger>
              <SelectContent>
                {v.allowAll && <SelectItem value={ALL}>all</SelectItem>}
                {(v.options ?? []).map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}
              </SelectContent>
            </Select>
          );
        })}
      </div>

      <div className="mt-4 grid grid-cols-2 gap-2.5">
        {panels.map((p, i) => (
          <div key={p.query} className={cn("rounded border bg-muted p-2.5", i === panels.length - 1 && panels.length % 2 === 1 && "col-span-2")}>
            <p className="text-[11px] text-card-foreground">{interpolate(p.title, values)}</p>
            <code className="mt-1 block font-mono text-[10px] text-muted-foreground">{p.query}</code>
          </div>
        ))}
      </div>

      <code className="mt-4 block break-all font-mono text-[10px] text-muted-foreground">{toSearch(values)}</code>
    </div>
  );
}

demo.tsxHow it is called: env, service and host chained, three panel queries, prod and checkout in the URL.

import { useState } from "react";
import { TemplateVariables, type Values, type Variable } from "./TemplateVariables";

/**
 * Three chained variables and three panels that name them. Service depends on
 * env, host on service; change env and the two below reset. The URL at the
 * bottom is what a real app would push into history on every change.
 */
const VARIABLES: Variable[] = [
  { name: "env", query: "label_values(env)", options: ["prod", "staging", "dev"] },
  {
    name: "service",
    query: "label_values(service)",
    dependsOn: "env",
    options: ["checkout", "search", "cart", "auth", "feed", "billing", "catalog", "email", "export", "gateway"],
  },
  {
    name: "host",
    query: "label_values(up{service=\"$service\"}, instance)",
    dependsOn: "service",
    allowAll: true,
    options: Array.from({ length: 40 }, (_, i) => `checkout-${String(i + 1).padStart(2, "0")}`),
  },
];

const PANELS = [
  { title: "$service error rate", query: "rate($service errors)" },
  { title: "$service p95 on $host", query: "p95($service, $host)" },
  { title: "pods up in $env", query: "sum by (pod) (up{env=\"$env\"})" },
];

export default function Demo() {
  const [values, setValues] = useState<Values>({ env: "prod", service: "checkout", host: "all" });
  return (
    <div className="max-w-[460px]">
      <TemplateVariables variables={VARIABLES} values={values} onChange={setValues} panels={PANELS} />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Grafana

The reference implementation for panel grids, template variables, and stat panels; most other tools are defined by how they differ from it.

Examples / Dashboard Variables September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
Six selectors across the top and the page is unusually honest about what they cost. The middle panel says there is a hidden variable called bestfood, currently set to pizza, which has no control anywhere on screen—state affecting the page that a viewer cannot see or change. The overview panel says that setting Instance and then changing the Prometheus source will clear your selection, because the two variables are chained and the second one invalidates the first. Both of those are correct behaviour and both are the reason parameterised pages get distrusted. What the top row gets right is the display: Instance and CPU Usage Type show their selected values as chips with an × rather than as a count, so what is filtering the page is legible without opening anything.
  • Template variable Six selectors bound to every panel's query, with the chosen values carried in the URL.
  • Filter bar Values as removable chips rather than a dropdown saying Instance (3).
  • Explain this metric Names a hidden variable set to pizza. Prose is doing the work a control should.
  • Categorical series palette Twelve-plus hostnames differing only in the middle digits, with twelve-plus colours to match.
  • Data table Four columns visible, the fourth cut mid-word, with no horizontal scroll offered.