Skip to content
KONIGI Patterns

Dashboards / Interaction / Template variable

‹ 7 of 8 ›

Template variable

People need to use one dashboard for different services, hosts, or environments.

Updated September 9, 2026

Problem

Maintaining separate dashboards for similar services or systems creates copies that drift apart.

Solution

Use shared selectors to choose the service, environment, or other instance shown by a reusable dashboard. Apply each selection consistently to the relevant panel queries.

Populate choices from current data where possible and show the active values clearly. Keep selections in the URL so people can share the same view.

For dependent choices, update the available options and show loading or invalid selections clearly. Limit multi-selection when it would produce an unreadable number of series.

Use when

The same dashboard structure works for several instances of the same kind of item.

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

People may overlook the selected scope and misread the results. Chained selections add loading steps, while large multi-selections crowd charts. Reusability can also encourage an oversized dashboard that serves too many tasks.

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 supports broader filtering controls. Panel grid holds the affected panels. Drill-down can set a selection on arrival.

Saved view preserves frequent choices. Overview then detail can use variables to move between scopes.

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

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Dashboard Variables September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
This dashboard demonstrates six selectors and explains how their values affect the page. Selected values appear as removable chips, making the active scope easy to read. A text panel also describes a hidden variable that has no visible control. Another note explains that changing the data source clears an instance selection. These dependencies need clear feedback when people change a value.