---
product: "AG Studio"
title: "Responsive Layout"
description: "Build a mobile view of a dashboard with responsive layout breakpoints."
framework: vue
version: "3.1.0"
related:
    - title: "Modes & Layout"
      url: "https://www.ag-grid.com/studio/vue/modes-layout/"
    - title: "Standalone Layout"
      url: "https://www.ag-grid.com/studio/vue/standalone-layout/"
    - title: "Theming"
      url: "https://www.ag-grid.com/studio/vue/theming/"
    - title: "Theme Builder"
      url: "https://www.ag-grid.com/studio/vue/theme-builder/"
    - title: "Localisation"
      url: "https://www.ag-grid.com/studio/vue/localisation/"
    - title: "Accessibility"
      url: "https://www.ag-grid.com/studio/vue/accessibility/"
    - title: "State"
      url: "https://www.ag-grid.com/studio/vue/state/"
    - title: "Figma Design System"
      url: "https://www.ag-grid.com/studio/vue/figma-design-system/"
llms: "https://www.ag-grid.com/studio/llms.txt"
---

# Responsive Layout

A dashboard laid out for a desktop screen is too wide for a phone. In view mode, Studio can lay the same widgets out differently as the canvas gets narrower. Use the `responsiveLayoutBreakpoints` property to define those layouts.

## Single Column Layout

The simplest mobile view stacks every widget in a single column. The example below starts out responsive, so on a small screen it already shows the single column layout. Click **Mobile** or **Desktop** to fix the canvas at that width, and **Reset** to return to the responsive layout.

#### Mobile View

```ts
import type {
  AgDataSourcesDefinition,
  AgExpressionFieldDefinition,
  AgFieldDefinition,
  AgPageLayoutState,
  AgPanelConfig,
  AgReportState,
  AgResponsiveLayoutBreakpoint,
  AgStudioTheme,
} from "ag-studio";
import {
  enableStudioDevValidations,
  singleColumnLayout,
  studioTheme,
} from "ag-studio";
import { AgStudio } from "ag-studio-vue3";
import { createApp, defineComponent, ref, shallowRef } from "vue";

if (process.env.NODE_ENV !== "production") {
  // Enable extended validations only for development
  enableStudioDevValidations();
}

const BASE_URL = "https://www.ag-grid.com/studio/example-assets/main-demo";

async function loadJson(filename: string): Promise<any[]> {
  const response = await fetch(`${BASE_URL}/${filename}`);
  if (!response.ok) {
    console.error(`Failed to load ${filename}: ${response.status}`);
    return [];
  }
  return response.json();
}

const productsFields: AgFieldDefinition[] = [
  { id: "product_id", name: "Product ID", format: "textFormat" },
  { id: "product_name", name: "Product", format: "textFormat" },
  { id: "category", name: "Category", format: "textFormat" },
  { id: "subcategory", name: "Subcategory", format: "textFormat" },
  { id: "brand", name: "Brand", format: "textFormat" },
  { id: "launch_date", name: "Launch Date", format: "dateFormat" },
  { id: "list_price", name: "List Price", format: "currencyFormat" },
  { id: "unit_cost", name: "Unit Cost", format: "currencyFormat" },
  { id: "is_discontinued", name: "Discontinued", format: "booleanFormat" },
];

const orderItemsFields: AgFieldDefinition[] = [
  { id: "order_item_id", name: "Order Item ID", format: "textFormat" },
  { id: "order_id", name: "Order ID", format: "textFormat" },
  { id: "product_id", name: "Product ID", format: "textFormat" },
  { id: "quantity", name: "Qty", format: "integerFormat" },
  { id: "unit_price", name: "Unit Price", format: "currencyFormat" },
  { id: "discount_pct", name: "Discount", format: "percentageFormat" },
  { id: "tax_rate", name: "Tax Rate", format: "percentageFormat" },
  { id: "returned", name: "Returned", format: "booleanFormat" },
  { id: "return_reason", name: "Return Reason", format: "textFormat" },
];

const customersFields: AgFieldDefinition[] = [
  { id: "customer_id", name: "Customer ID", format: "textFormat" },
  { id: "customer_name", name: "Customer", format: "textFormat" },
  { id: "region", name: "Region", format: "textFormat" },
  { id: "segment", name: "Segment", format: "textFormat" },
  { id: "industry", name: "Industry", format: "textFormat" },
];

const ordersFields: AgFieldDefinition[] = [
  {
    id: "order_id",
    name: "Order ID",
    format: "textFormat",
    cardinality: "high",
    notBlank: true,
  },
  { id: "customer_id", name: "Customer ID", format: "textFormat" },
  { id: "order_datetime", name: "Order Date/Time", format: "dateTimeFormat" },
  { id: "channel", name: "Channel", format: "textFormat" },
  { id: "status", name: "Status", format: "textFormat" },
  { id: "payment_method", name: "Payment Method", format: "textFormat" },
  { id: "currency", name: "Currency", format: "textFormat" },
];

const expressions: AgExpressionFieldDefinition[] = [
  {
    id: "line_gross",
    name: "Line Gross",
    isMeasure: false,
    format: "currencyFormat",
    formatOptions: { format: "$#,##0.0,K" },
    expression: {
      operator: "multiply",
      inputs: [
        { id: "order_items.quantity" },
        { id: "order_items.unit_price" },
      ],
    },
  },
  {
    id: "margin",
    name: "Margin",
    isMeasure: false,
    format: "currencyFormat",
    expression: {
      operator: "subtract",
      inputs: [{ id: "products.list_price" }, { id: "order_items.unit_price" }],
    },
  },
  {
    id: "line_net",
    name: "Line Net",
    isMeasure: false,
    format: "currencyFormat",
    expression: {
      operator: "subtract",
      inputs: [
        {
          operator: "multiply",
          inputs: [
            { id: "order_items.quantity" },
            { id: "order_items.unit_price" },
          ],
        },
        {
          operator: "multiply",
          inputs: [
            {
              operator: "multiply",
              inputs: [
                { id: "order_items.quantity" },
                { id: "order_items.unit_price" },
              ],
            },
            { id: "order_items.discount_pct" },
          ],
        },
      ],
    },
  },
];

const initialState: AgReportState = {
  pages: [
    {
      id: "overview",
      widgets: {
        "kpi-gross-sales": {
          type: "value",
          dataMapping: { value: [{ id: "line_gross", aggregation: "sum" }] },
          format: { style: { label: { enabled: true, text: "Gross Sales" } } },
        },
        "kpi-order-count": {
          type: "value",
          dataMapping: {
            value: [{ id: "orders.order_id", aggregation: "countd" }],
          },
          format: { style: { label: { enabled: true, text: "Order Count" } } },
        },
        "kpi-avg-qty": {
          type: "value",
          dataMapping: {
            value: [{ id: "order_items.quantity", aggregation: "avg" }],
          },
          format: {
            style: { label: { enabled: true, text: "Avg Qty per Line" } },
          },
        },
        "sales-by-category": {
          type: "bar-chart-grouped",
          dataMapping: {
            categoryKey: [{ id: "products.subcategory" }],
            valueKey: [{ id: "line_gross", aggregation: "sum" }],
          },
          sort: [
            {
              field: { id: "line_gross", aggregation: "sum" },
              direction: "desc",
            },
          ],
          format: {
            title: { enabled: true, text: "Gross Sales by Subcategory" },
          },
        },
        "sales-by-brand": {
          type: "bar-chart-grouped",
          dataMapping: {
            categoryKey: [{ id: "products.brand" }],
            valueKey: [{ id: "line_gross", aggregation: "sum" }],
          },
          sort: [
            {
              field: { id: "line_gross", aggregation: "sum" },
              direction: "desc",
            },
          ],
          format: { title: { enabled: true, text: "Gross Sales by Brand" } },
        },
        "top-products": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "products.product_name" },
              { id: "products.category" },
              { id: "order_items.quantity", aggregation: "sum" },
              { id: "line_gross", aggregation: "sum" },
              { id: "margin", aggregation: "avg" },
            ],
          },
          sort: [
            {
              field: { id: "line_gross", aggregation: "sum" },
              direction: "desc",
            },
          ],
          format: { title: { enabled: true, text: "Top Products" } },
        },
      },
      widgetLayout: {
        "kpi-gross-sales": { xTrack: 0, yTrack: 0, xSpan: 8, ySpan: 6 },
        "kpi-order-count": { xTrack: 8, yTrack: 0, xSpan: 8, ySpan: 6 },
        "kpi-avg-qty": { xTrack: 16, yTrack: 0, xSpan: 8, ySpan: 6 },
        "sales-by-category": { xTrack: 0, yTrack: 6, xSpan: 12, ySpan: 16 },
        "sales-by-brand": { xTrack: 12, yTrack: 6, xSpan: 12, ySpan: 16 },
        "top-products": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
      },
    },

    {
      id: "detail",
      widgets: {
        "subcategory-filter": {
          type: "list-filter",
          dataMapping: {
            value: [{ id: "products.subcategory" }],
          },
          format: {
            title: { enabled: true, text: "Subcategory" },
          },
        },
        "order-grid": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "products.product_name" },
              { id: "products.subcategory" },
              { id: "order_items.quantity" },
              { id: "order_items.unit_price" },
              { id: "line_gross" },
              { id: "line_net" },
            ],
          },
          format: {
            title: { enabled: true, text: "Order Details" },
          },
        },
      },
      widgetLayout: {
        "subcategory-filter": { xTrack: 0, yTrack: 0, xSpan: 6, ySpan: 32 },
        "order-grid": { xTrack: 6, yTrack: 0, xSpan: 18, ySpan: 32 },
      },
    },

    {
      id: "customers",
      widgets: {
        "kpi-unique-customers": {
          type: "value",
          dataMapping: {
            value: [{ id: "customers.customer_id", aggregation: "countd" }],
          },
          format: {
            style: { label: { enabled: true, text: "Unique Customers" } },
          },
        },
        "revenue-by-region": {
          type: "bar-chart-grouped",
          dataMapping: {
            categoryKey: [{ id: "customers.region" }],
            valueKey: [{ id: "line_net", aggregation: "sum" }],
          },
          sort: [
            {
              field: { id: "line_net", aggregation: "sum" },
              direction: "desc",
            },
          ],
          format: { title: { enabled: true, text: "Revenue by Region" } },
        },
        "customer-grid": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "customers.customer_name" },
              { id: "customers.region" },
              { id: "customers.segment" },
              { id: "customers.industry" },
              { id: "orders.order_id", aggregation: "countd" },
              { id: "line_net", aggregation: "sum" },
            ],
          },
          sort: [
            {
              field: { id: "line_net", aggregation: "sum" },
              direction: "desc",
            },
          ],
          format: { title: { enabled: true, text: "Customer Details" } },
        },
      },
      widgetLayout: {
        "kpi-unique-customers": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 6 },
        "revenue-by-region": { xTrack: 0, yTrack: 6, xSpan: 24, ySpan: 16 },
        "customer-grid": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
      },
    },
  ],
  selectedPageId: "overview",
  panels: {
    filters: {
      collapsed: true,
    },
    edit: {
      collapsed: true,
    },
    data: {
      collapsed: true,
    },
  },
};

function loadData(): Promise<AgDataSourcesDefinition> {
  return Promise.all([
    loadJson("products.json"),
    loadJson("order_items.json"),
    loadJson("customers.json"),
    loadJson("orders.json"),
  ]).then(([productData, orderItemData, customerData, orderData]) => ({
    sources: [
      {
        id: "products",
        name: "Products",
        data: productData,
        fields: productsFields,
      },
      {
        id: "order_items",
        name: "Order Items",
        data: orderItemData,
        fields: orderItemsFields,
      },
      {
        id: "customers",
        name: "Customers",
        data: customerData,
        fields: customersFields,
      },
      { id: "orders", name: "Orders", data: orderData, fields: ordersFields },
    ],
    relationships: [
      {
        id: "order-item-product",
        source: "order_items.product_id",
        target: "products.product_id",
        type: "many-to-one",
        acceptFanout: true,
      },
      {
        id: "order-item-order",
        source: "order_items.order_id",
        target: "orders.order_id",
        type: "many-to-one",
      },
      {
        id: "order-customer",
        source: "orders.customer_id",
        target: "customers.customer_id",
        type: "many-to-one",
      },
    ],
    expressions,
  }));
}

const PAGE_MIN_WIDTH = 250;
const MOBILE_MAX_WIDTH = 500;

const singleColumnBreakpoint: AgResponsiveLayoutBreakpoint = {
  maxWidth: MOBILE_MAX_WIDTH,
  layout: singleColumnLayout,
};

type LayoutMode = "Responsive" | "Desktop" | "Mobile";

// Canvas width forced by each mode. Responsive follows the width of the screen.
const LAYOUT_MODE_WIDTHS: Record<LayoutMode, number | undefined> = {
  Responsive: undefined,
  Desktop: 1024,
  Mobile: 375,
};

function setStudioWidth(width: number | undefined) {
  const studioDiv = document.querySelector<HTMLElement>(
    ".ag-studio-layout-canvas",
  )!;
  studioDiv.style.width = width != null ? `${width}px` : "";
}

const VueExample = defineComponent({
  template: `
        <div style="display: flex; flex-direction: column; height: 100%">
            <div class="example-controls">
                <div class="controls-row">
                    <button :aria-pressed="layoutMode === 'Desktop'" v-on:click="showLayout('Desktop')">Desktop</button>
                    <button :aria-pressed="layoutMode === 'Mobile'" v-on:click="showLayout('Mobile')">Mobile</button>
                    <button v-on:click="showLayout('Responsive')">Reset</button>
                    <span>Layout: {{ layoutMode }}</span>
                </div>
            </div>
            <ag-studio
                style="height: 100%; width: 100%"
                class="my-studio-container"
                mode="view"
                :initialState="initialState"
                :data="data"
                :theme="theme"
                :layout="pageLayout"
                :responsiveLayoutBreakpoints="breakpoints"
                :panels="panels"></ag-studio>
        </div>
    `,
  components: {
    "ag-studio": AgStudio,
  },
  setup() {
    const theme: AgStudioTheme = studioTheme.withParams({ studioMinWidth: 0 });
    const pageLayout: Partial<AgPageLayoutState> = { minWidth: PAGE_MIN_WIDTH };
    const breakpoints: AgResponsiveLayoutBreakpoint[] = [
      singleColumnBreakpoint,
    ];
    const panels: AgPanelConfig = {
      view: {
        left: [],
        right: [],
      },
    };
    const layoutMode = ref<LayoutMode>("Responsive");
    const showLayout = (mode: LayoutMode) => {
      layoutMode.value = mode;
      setStudioWidth(LAYOUT_MODE_WIDTHS[mode]);
    };
    const data = shallowRef<AgDataSourcesDefinition>();

    loadData().then((loaded) => (data.value = loaded));

    return {
      initialState,
      theme,
      pageLayout,
      breakpoints,
      panels,
      layoutMode,
      showLayout,
      data,
    };
  },
});

const app = createApp(VueExample);
app.mount("#app");
```

[Live example: Mobile View](https://www.ag-grid.com/studio/examples/responsive-layout/mobile-view/vue3/)

Import the `singleColumnLayout` function from `ag-studio` to convert a desktop layout into a single column:

- A heuristic orders the widgets by their visual position within the desktop layout.
- Each widget spans the full width of the canvas.
- Each widget keeps its desktop height.

```ts
<ag-studio
    :mode="mode"
    :theme="theme"
    :layout="layout"
    :responsiveLayoutBreakpoints="responsiveLayoutBreakpoints"
    /* other studio properties ... */>
</ag-studio>

this.mode = 'view';
this.theme = studioTheme.withParams({ studioMinWidth: 0 });
this.layout = {
    minWidth: 250
};
this.responsiveLayoutBreakpoints = [
    {
        maxWidth: 500,
        layout: singleColumnLayout,
    },
];
```

> **Note**
>
> Two minimum widths limit how narrow things can get, and a mobile layout needs both lowered, as in the example above.
>
> The outer Studio component has its own [Min Width](https://www.ag-grid.com/studio/vue/studio-theme/#reference-AgStudioCoreThemeParams-studioMinWidth), which defaults to 600px and limits how narrow Studio itself can be. The page's [Min Width](https://www.ag-grid.com/studio/vue/modes-layout/#width) limits how narrow the canvas inside it can be drawn, and a breakpoint below that width is never used.

## Breakpoints

Each breakpoint covers a range of canvas widths:

- `minWidth` is the narrowest canvas width the breakpoint applies to. Omit it to leave the range open below.
- `maxWidth` is the widest canvas width the breakpoint applies to. Omit it to leave the range open above.
- `layout` is a function that returns the position and size of each widget for that range.

Studio uses the first breakpoint whose range contains the canvas width, so list narrower ranges before wider ones that also contain them. Where two ranges meet on the same pixel, the breakpoint listed first takes it.

Canvas widths that no breakpoint covers keep the desktop layout, so a range that should use it needs no breakpoint at all. Omitting `layout` does the same for a range you declare anyway, which is how to stop a later, wider breakpoint from covering it.

The canvas is never drawn narrower than the page's [Min Width](https://www.ag-grid.com/studio/vue/modes-layout/#width), so lowering that minimum is what brings a narrow breakpoint into range. Cover the widths it opens up: any width above the page minimum that no breakpoint covers renders the desktop layout at that width, however narrow it is.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `responsiveLayoutBreakpoints` | `AgResponsiveLayoutBreakpoint[]` |  | Breakpoints that lay widgets out differently on narrower canvases while in view mode. The first breakpoint whose range contains the canvas width is the one used, so list narrower ranges before wider ones that also contain them. Either bound can be omitted to leave that end of the range open, and canvas widths no breakpoint covers keep the layout the dashboard was authored with. The canvas is never drawn narrower than the page's own minimum width, so a breakpoint below that width is never used. Lower the page's minimum width to bring it into range. |

The `layout` function is called with two arguments:

- A list of widgets, each with its desktop `layout` (`xTrack`, `yTrack`, `xSpan` and `ySpan`) and its `minSize` (`xSpan` and `ySpan`). The minimum size is converted to columns and rows at the narrowest canvas width that reaches the breakpoint, so one layout holds across the whole range.
- The layout of the page at that width, including the number of `columns`, and `rows` if there is a height constraint.

The function returns one layout for each widget, in the same order as the list it was given. Positions and sizes are in columns and rows, as in the desktop layout.

## Custom Layout

Write your own `layout` function for anything other than a single column. In the example below, **Mobile** uses a single column, **Tablet** uses two columns, **Desktop** uses the desktop layout and **Ultra Wide** uses four columns. As in the previous example, Studio starts out responsive and **Reset** returns to that.

#### Custom Layout

```ts
import type {
  AgDataSourcesDefinition,
  AgExpressionFieldDefinition,
  AgFieldDefinition,
  AgPageLayoutState,
  AgPanelConfig,
  AgReportState,
  AgResponsiveLayoutBreakpoint,
  AgResponsiveLayoutCandidate,
  AgResponsiveLayoutState,
  AgStudioTheme,
  AgWidgetLayoutState,
} from "ag-studio";
import {
  enableStudioDevValidations,
  singleColumnLayout,
  studioTheme,
} from "ag-studio";
import { AgStudio } from "ag-studio-vue3";
import { createApp, defineComponent, ref, shallowRef } from "vue";

if (process.env.NODE_ENV !== "production") {
  // Enable extended validations only for development
  enableStudioDevValidations();
}

const BASE_URL = "https://www.ag-grid.com/studio/example-assets/main-demo";

async function loadJson(filename: string): Promise<any[]> {
  const response = await fetch(`${BASE_URL}/${filename}`);
  if (!response.ok) {
    console.error(`Failed to load ${filename}: ${response.status}`);
    return [];
  }
  return response.json();
}

const productsFields: AgFieldDefinition[] = [
  { id: "product_id", name: "Product ID", format: "textFormat" },
  { id: "product_name", name: "Product", format: "textFormat" },
  { id: "category", name: "Category", format: "textFormat" },
  { id: "subcategory", name: "Subcategory", format: "textFormat" },
  { id: "brand", name: "Brand", format: "textFormat" },
  { id: "launch_date", name: "Launch Date", format: "dateFormat" },
  { id: "list_price", name: "List Price", format: "currencyFormat" },
  { id: "unit_cost", name: "Unit Cost", format: "currencyFormat" },
  { id: "is_discontinued", name: "Discontinued", format: "booleanFormat" },
];

const orderItemsFields: AgFieldDefinition[] = [
  { id: "order_item_id", name: "Order Item ID", format: "textFormat" },
  { id: "order_id", name: "Order ID", format: "textFormat" },
  { id: "product_id", name: "Product ID", format: "textFormat" },
  { id: "quantity", name: "Qty", format: "integerFormat" },
  { id: "unit_price", name: "Unit Price", format: "currencyFormat" },
  { id: "discount_pct", name: "Discount", format: "percentageFormat" },
  { id: "tax_rate", name: "Tax Rate", format: "percentageFormat" },
  { id: "returned", name: "Returned", format: "booleanFormat" },
  { id: "return_reason", name: "Return Reason", format: "textFormat" },
];

const customersFields: AgFieldDefinition[] = [
  { id: "customer_id", name: "Customer ID", format: "textFormat" },
  { id: "customer_name", name: "Customer", format: "textFormat" },
  { id: "region", name: "Region", format: "textFormat" },
  { id: "segment", name: "Segment", format: "textFormat" },
  { id: "industry", name: "Industry", format: "textFormat" },
];

const ordersFields: AgFieldDefinition[] = [
  {
    id: "order_id",
    name: "Order ID",
    format: "textFormat",
    cardinality: "high",
    notBlank: true,
  },
  { id: "customer_id", name: "Customer ID", format: "textFormat" },
  { id: "order_datetime", name: "Order Date/Time", format: "dateTimeFormat" },
  { id: "channel", name: "Channel", format: "textFormat" },
  { id: "status", name: "Status", format: "textFormat" },
  { id: "payment_method", name: "Payment Method", format: "textFormat" },
  { id: "currency", name: "Currency", format: "textFormat" },
];

const expressions: AgExpressionFieldDefinition[] = [
  {
    id: "line_gross",
    name: "Line Gross",
    isMeasure: false,
    format: "currencyFormat",
    formatOptions: { format: "$#,##0.0,K" },
    expression: {
      operator: "multiply",
      inputs: [
        { id: "order_items.quantity" },
        { id: "order_items.unit_price" },
      ],
    },
  },
  {
    id: "margin",
    name: "Margin",
    isMeasure: false,
    format: "currencyFormat",
    expression: {
      operator: "subtract",
      inputs: [{ id: "products.list_price" }, { id: "order_items.unit_price" }],
    },
  },
  {
    id: "line_net",
    name: "Line Net",
    isMeasure: false,
    format: "currencyFormat",
    expression: {
      operator: "subtract",
      inputs: [
        {
          operator: "multiply",
          inputs: [
            { id: "order_items.quantity" },
            { id: "order_items.unit_price" },
          ],
        },
        {
          operator: "multiply",
          inputs: [
            {
              operator: "multiply",
              inputs: [
                { id: "order_items.quantity" },
                { id: "order_items.unit_price" },
              ],
            },
            { id: "order_items.discount_pct" },
          ],
        },
      ],
    },
  },
];

const initialState: AgReportState = {
  pages: [
    {
      id: "overview",
      widgets: {
        "kpi-gross-sales": {
          type: "value",
          dataMapping: { value: [{ id: "line_gross", aggregation: "sum" }] },
          format: { style: { label: { enabled: true, text: "Gross Sales" } } },
        },
        "kpi-order-count": {
          type: "value",
          dataMapping: {
            value: [{ id: "orders.order_id", aggregation: "countd" }],
          },
          format: { style: { label: { enabled: true, text: "Order Count" } } },
        },
        "kpi-avg-qty": {
          type: "value",
          dataMapping: {
            value: [{ id: "order_items.quantity", aggregation: "avg" }],
          },
          format: {
            style: { label: { enabled: true, text: "Avg Qty per Line" } },
          },
        },
        "kpi-net-sales": {
          type: "value",
          dataMapping: { value: [{ id: "line_net", aggregation: "sum" }] },
          format: { style: { label: { enabled: true, text: "Net Sales" } } },
        },
        "kpi-avg-discount": {
          type: "value",
          dataMapping: {
            value: [{ id: "order_items.discount_pct", aggregation: "avg" }],
          },
          format: { style: { label: { enabled: true, text: "Avg Discount" } } },
        },
        "kpi-avg-unit-price": {
          type: "value",
          dataMapping: {
            value: [{ id: "order_items.unit_price", aggregation: "avg" }],
          },
          format: {
            style: { label: { enabled: true, text: "Avg Unit Price" } },
          },
        },
        "sales-by-category": {
          type: "bar-chart-grouped",
          dataMapping: {
            categoryKey: [{ id: "products.subcategory" }],
            valueKey: [{ id: "line_gross", aggregation: "sum" }],
          },
          sort: [
            {
              field: { id: "line_gross", aggregation: "sum" },
              direction: "desc",
            },
          ],
          format: {
            title: { enabled: true, text: "Gross Sales by Subcategory" },
          },
        },
        "sales-by-brand": {
          type: "bar-chart-grouped",
          dataMapping: {
            categoryKey: [{ id: "products.brand" }],
            valueKey: [{ id: "line_gross", aggregation: "sum" }],
          },
          sort: [
            {
              field: { id: "line_gross", aggregation: "sum" },
              direction: "desc",
            },
          ],
          format: { title: { enabled: true, text: "Gross Sales by Brand" } },
        },
        "top-products": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "products.product_name" },
              { id: "products.category" },
              { id: "order_items.quantity", aggregation: "sum" },
              { id: "line_gross", aggregation: "sum" },
              { id: "margin", aggregation: "avg" },
            ],
          },
          sort: [
            {
              field: { id: "line_gross", aggregation: "sum" },
              direction: "desc",
            },
          ],
          format: { title: { enabled: true, text: "Top Products" } },
        },
      },
      widgetLayout: {
        "kpi-gross-sales": { xTrack: 0, yTrack: 0, xSpan: 8, ySpan: 6 },
        "kpi-order-count": { xTrack: 8, yTrack: 0, xSpan: 8, ySpan: 6 },
        "kpi-avg-qty": { xTrack: 16, yTrack: 0, xSpan: 8, ySpan: 6 },
        "kpi-net-sales": { xTrack: 0, yTrack: 6, xSpan: 8, ySpan: 6 },
        "kpi-avg-discount": { xTrack: 8, yTrack: 6, xSpan: 8, ySpan: 6 },
        "kpi-avg-unit-price": { xTrack: 16, yTrack: 6, xSpan: 8, ySpan: 6 },
        "sales-by-category": { xTrack: 0, yTrack: 12, xSpan: 12, ySpan: 16 },
        "sales-by-brand": { xTrack: 12, yTrack: 12, xSpan: 12, ySpan: 16 },
        "top-products": { xTrack: 0, yTrack: 28, xSpan: 24, ySpan: 16 },
      },
    },

    {
      id: "detail",
      widgets: {
        "subcategory-filter": {
          type: "list-filter",
          dataMapping: {
            value: [{ id: "products.subcategory" }],
          },
          format: {
            title: { enabled: true, text: "Subcategory" },
          },
        },
        "order-grid": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "products.product_name" },
              { id: "products.subcategory" },
              { id: "order_items.quantity" },
              { id: "order_items.unit_price" },
              { id: "line_gross" },
              { id: "line_net" },
            ],
          },
          format: {
            title: { enabled: true, text: "Order Details" },
          },
        },
      },
      widgetLayout: {
        "subcategory-filter": { xTrack: 0, yTrack: 0, xSpan: 6, ySpan: 32 },
        "order-grid": { xTrack: 6, yTrack: 0, xSpan: 18, ySpan: 32 },
      },
    },

    {
      id: "customers",
      widgets: {
        "kpi-unique-customers": {
          type: "value",
          dataMapping: {
            value: [{ id: "customers.customer_id", aggregation: "countd" }],
          },
          format: {
            style: { label: { enabled: true, text: "Unique Customers" } },
          },
        },
        "revenue-by-region": {
          type: "bar-chart-grouped",
          dataMapping: {
            categoryKey: [{ id: "customers.region" }],
            valueKey: [{ id: "line_net", aggregation: "sum" }],
          },
          sort: [
            {
              field: { id: "line_net", aggregation: "sum" },
              direction: "desc",
            },
          ],
          format: { title: { enabled: true, text: "Revenue by Region" } },
        },
        "customer-grid": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "customers.customer_name" },
              { id: "customers.region" },
              { id: "customers.segment" },
              { id: "customers.industry" },
              { id: "orders.order_id", aggregation: "countd" },
              { id: "line_net", aggregation: "sum" },
            ],
          },
          sort: [
            {
              field: { id: "line_net", aggregation: "sum" },
              direction: "desc",
            },
          ],
          format: { title: { enabled: true, text: "Customer Details" } },
        },
      },
      widgetLayout: {
        "kpi-unique-customers": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 6 },
        "revenue-by-region": { xTrack: 0, yTrack: 6, xSpan: 24, ySpan: 16 },
        "customer-grid": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
      },
    },
  ],
  selectedPageId: "overview",
  panels: {
    filters: {
      collapsed: true,
    },
    edit: {
      collapsed: true,
    },
    data: {
      collapsed: true,
    },
  },
};

function loadData(): Promise<AgDataSourcesDefinition> {
  return Promise.all([
    loadJson("products.json"),
    loadJson("order_items.json"),
    loadJson("customers.json"),
    loadJson("orders.json"),
  ]).then(([productData, orderItemData, customerData, orderData]) => ({
    sources: [
      {
        id: "products",
        name: "Products",
        data: productData,
        fields: productsFields,
      },
      {
        id: "order_items",
        name: "Order Items",
        data: orderItemData,
        fields: orderItemsFields,
      },
      {
        id: "customers",
        name: "Customers",
        data: customerData,
        fields: customersFields,
      },
      { id: "orders", name: "Orders", data: orderData, fields: ordersFields },
    ],
    relationships: [
      {
        id: "order-item-product",
        source: "order_items.product_id",
        target: "products.product_id",
        type: "many-to-one",
        acceptFanout: true,
      },
      {
        id: "order-item-order",
        source: "order_items.order_id",
        target: "orders.order_id",
        type: "many-to-one",
      },
      {
        id: "order-customer",
        source: "orders.customer_id",
        target: "customers.customer_id",
        type: "many-to-one",
      },
    ],
    expressions,
  }));
}

function masonryLayout(
  horizontalColumns: number,
  layouts: AgResponsiveLayoutCandidate[],
  state: AgResponsiveLayoutState,
): AgWidgetLayoutState[] {
  const order = Array.from(layouts.keys()).sort((aIndex, bIndex) => {
    const a = layouts[aIndex].layout;
    const b = layouts[bIndex].layout;
    return a.yTrack - b.yTrack || a.xTrack - b.xTrack;
  });

  const columnWidth = state.columns / horizontalColumns;
  const result = new Array<AgWidgetLayoutState>(layouts.length);

  const columnTrailingWidgets = new Array<AgWidgetLayoutState | null>(
    horizontalColumns,
  ).fill(null);

  for (const index of order) {
    const { layout, minSize } = layouts[index];

    const horizontalSpan = Math.max(
      Math.round(layout.xSpan / columnWidth),
      Math.ceil(minSize.xSpan / columnWidth),
    );
    let horizontalIndex = 0;
    let yTrack: number | undefined;
    for (let i = 0; i <= horizontalColumns - horizontalSpan; i += 1) {
      const testYTrack = columnTrailingWidgets
        .slice(i, i + horizontalSpan)
        .reduce(
          (max, widget) =>
            Math.max(max, (widget?.yTrack ?? 0) + (widget?.ySpan ?? 0)),
          0,
        );

      if (yTrack == null || testYTrack < yTrack) {
        yTrack = testYTrack;
        horizontalIndex = i;
      }
    }

    const nextLayout: AgWidgetLayoutState = {
      xTrack: horizontalIndex * columnWidth,
      yTrack: yTrack ?? 0,
      xSpan: horizontalSpan * columnWidth,
      ySpan: layout.ySpan,
    };

    // Update the column trailing widgets with the new layout
    for (
      let i = horizontalIndex;
      i < horizontalIndex + horizontalSpan;
      i += 1
    ) {
      columnTrailingWidgets[i] = nextLayout;
    }

    result[index] = nextLayout;
  }

  for (const layout of result) {
    const otherLayouts = result.filter((other) => other !== layout);

    // Extend down
    const nextYTrack = otherLayouts
      .filter(
        (other) =>
          overlapsHorizontally(layout, other) &&
          other.yTrack >= layout.yTrack + layout.ySpan,
      )
      .reduce((min, other) => Math.min(min, other.yTrack), Infinity);
    if (Number.isFinite(nextYTrack)) {
      layout.ySpan = nextYTrack - layout.yTrack;
    }

    const { yTrack, ySpan } = layout;

    // Extend left
    while (
      layout.xTrack > 0 &&
      isAreaFree(
        otherLayouts,
        layout.xTrack - columnWidth,
        columnWidth,
        yTrack,
        ySpan,
      )
    ) {
      layout.xTrack -= columnWidth;
      layout.xSpan += columnWidth;
    }

    // Extend right
    while (
      layout.xTrack + layout.xSpan < state.columns &&
      isAreaFree(
        otherLayouts,
        layout.xTrack + layout.xSpan,
        columnWidth,
        yTrack,
        ySpan,
      )
    ) {
      layout.xSpan += columnWidth;
    }
  }

  return result;
}

function overlapsHorizontally(
  a: AgWidgetLayoutState,
  b: AgWidgetLayoutState,
): boolean {
  return a.xTrack < b.xTrack + b.xSpan && b.xTrack < a.xTrack + a.xSpan;
}

function isAreaFree(
  layouts: AgWidgetLayoutState[],
  xTrack: number,
  xSpan: number,
  yTrack: number,
  ySpan: number,
): boolean {
  return layouts.every((other) => {
    return (
      other.xTrack + other.xSpan <= xTrack ||
      other.xTrack >= xTrack + xSpan ||
      other.yTrack + other.ySpan <= yTrack ||
      other.yTrack >= yTrack + ySpan
    );
  });
}

type LayoutMode = "Responsive" | "Mobile" | "Tablet" | "Desktop" | "Ultra Wide";

// Canvas width forced by each mode. Responsive follows the width of the screen.
const LAYOUT_MODE_WIDTHS: Record<LayoutMode, number | undefined> = {
  Responsive: undefined,
  Mobile: 375,
  Tablet: 600,
  Desktop: 1024,
  "Ultra Wide": 1440,
};

const responsiveLayoutBreakpoints: AgResponsiveLayoutBreakpoint[] = [
  { maxWidth: 480, layout: singleColumnLayout },
  { maxWidth: 720, layout: (...args) => masonryLayout(2, ...args) },
  // Widths from 720 to 1200 match no breakpoint, so they keep the desktop layout.
  { minWidth: 1200, layout: (...args) => masonryLayout(4, ...args) },
];

function setStudioWidth(width: number | undefined) {
  const studioDiv = document.querySelector<HTMLElement>(
    ".ag-studio-layout-canvas",
  )!;
  studioDiv.style.width = width != null ? `${width}px` : "";
}

const VueExample = defineComponent({
  template: `
        <div style="display: flex; flex-direction: column; height: 100%">
            <div class="example-controls">
                <div class="controls-row">
                    <button :aria-pressed="layoutMode === 'Mobile'" v-on:click="showLayout('Mobile')">Mobile</button>
                    <button :aria-pressed="layoutMode === 'Tablet'" v-on:click="showLayout('Tablet')">Tablet</button>
                    <button :aria-pressed="layoutMode === 'Desktop'" v-on:click="showLayout('Desktop')">Desktop</button>
                    <button :aria-pressed="layoutMode === 'Ultra Wide'" v-on:click="showLayout('Ultra Wide')">Ultra Wide</button>
                    <button v-on:click="showLayout('Responsive')">Reset</button>
                    <span>Layout: {{ layoutMode }}</span>
                </div>
            </div>
            <ag-studio
                style="height: 100%; width: 100%"
                class="my-studio-container"
                mode="view"
                :initialState="initialState"
                :data="data"
                :theme="theme"
                :layout="pageLayout"
                :responsiveLayoutBreakpoints="breakpoints"
                :panels="panels"></ag-studio>
        </div>
    `,
  components: {
    "ag-studio": AgStudio,
  },
  setup() {
    const theme: AgStudioTheme = studioTheme.withParams({ studioMinWidth: 0 });
    const pageLayout: Partial<AgPageLayoutState> = { minWidth: 250 };
    const breakpoints: AgResponsiveLayoutBreakpoint[] =
      responsiveLayoutBreakpoints;
    const panels: AgPanelConfig = {
      view: {
        left: [],
        right: [],
      },
    };
    const layoutMode = ref<LayoutMode>("Responsive");
    const showLayout = (mode: LayoutMode) => {
      layoutMode.value = mode;
      setStudioWidth(LAYOUT_MODE_WIDTHS[mode]);
    };
    const data = shallowRef<AgDataSourcesDefinition>();

    loadData().then((loaded) => (data.value = loaded));

    return {
      initialState,
      theme,
      pageLayout,
      breakpoints,
      panels,
      layoutMode,
      showLayout,
      data,
    };
  },
});

const app = createApp(VueExample);
app.mount("#app");
```

[Live example: Custom Layout](https://www.ag-grid.com/studio/examples/responsive-layout/custom-layout/vue3/)

The example defines three breakpoints:

- Canvases up to 480px wide use `singleColumnLayout`.
- Canvases from 480px to 720px use a custom `masonryLayout` function with two columns.
- Canvases wider than 1200px use `masonryLayout` with four columns.
- Canvases between 720px and 1200px match no breakpoint, so they use the desktop layout.

```ts
<ag-studio
    :layout="layout"
    :responsiveLayoutBreakpoints="responsiveLayoutBreakpoints"
    /* other studio properties ... */>
</ag-studio>

this.layout = { minWidth: 250 };
this.responsiveLayoutBreakpoints = [
    { maxWidth: 480, layout: singleColumnLayout },
    { maxWidth: 720, layout: (...args) => masonryLayout(2, ...args) },
    { minWidth: 1200, layout: (...args) => masonryLayout(4, ...args) },
];
```

`masonryLayout` places the widgets in desktop order. Each widget spans as many columns as its desktop width needs, and goes wherever it can sit highest. Once every widget is placed, each one stretches down and sideways to fill any empty space next to it. It is a worked example of a `layout` function rather than part of Studio, so its implementation lives in the example above: open the example's code to read it alongside the breakpoints that call it.

## Switching Between Desktop and Mobile

Studio chooses the layout from the current width of the canvas and updates it whenever the canvas is resized. There is no API call to switch between the desktop and mobile layouts. The buttons in the examples above only change the width of the canvas inside Studio.

Studio must be in view mode. In edit mode, it always uses the desktop layout, whatever the width.

## Widget Minimum Sizes

Every widget has a minimum size. A widget's minimum width must fit within the width of the canvas, so widget minimum sizes need to align with the widths your breakpoints support.

The built-in widgets have small minimum sizes, so they fit the breakpoints shown on this page. If you define [Custom Widgets](https://www.ag-grid.com/studio/vue/custom-widgets/), keep each widget's `minSize` width no wider than the page's [Min Width](https://www.ag-grid.com/studio/vue/modes-layout/#width), which is the narrowest the canvas is ever drawn. A custom layout function also receives each widget's minimum size, so it can give a wide widget more room, as `masonryLayout` does.

## Mobile Overlay

The example below shows one way to preview the mobile layout while editing a dashboard. Click **Toggle Mobile Overlay** to open a resizable popup that shows the dashboard in a single column. Edit the dashboard and the preview updates.

#### Mobile Overlay

```ts
import {
  createApp,
  defineComponent,
  onBeforeMount,
  ref,
  shallowRef,
} from "vue";
import { AgStudio } from "ag-studio-vue3";
import {
  AgDataEngine,
  AgDataSourcesDefinition,
  AgExpressionFieldDefinition,
  AgFieldDefinition,
  AgPopupInstance,
  AgReportHistory,
  AgReportState,
  AgStudioApi,
  AgStudioApiReadyEvent,
  AgStudioMode,
  AgStudioProperties,
  enableStudioDevValidations,
} from "ag-studio";
import MobileOverlay from "./mobileOverlayVue.ts";
import { MobileOverlayParams } from "./interfaces.ts";

if (process.env.NODE_ENV !== "production") {
  // Enable extended validations only for development
  enableStudioDevValidations();
}

const BASE_URL = "https://www.ag-grid.com/studio/example-assets/main-demo";

async function loadJson(filename: string): Promise<any[]> {
  const response = await fetch(`${BASE_URL}/${filename}`);
  if (!response.ok) {
    console.error(`Failed to load ${filename}: ${response.status}`);
    return [];
  }
  return response.json();
}

const productsFields: AgFieldDefinition[] = [
  { id: "product_id", name: "Product ID", format: "textFormat" },
  { id: "product_name", name: "Product", format: "textFormat" },
  { id: "category", name: "Category", format: "textFormat" },
  { id: "subcategory", name: "Subcategory", format: "textFormat" },
  { id: "brand", name: "Brand", format: "textFormat" },
  { id: "launch_date", name: "Launch Date", format: "dateFormat" },
  { id: "list_price", name: "List Price", format: "currencyFormat" },
  { id: "unit_cost", name: "Unit Cost", format: "currencyFormat" },
  { id: "is_discontinued", name: "Discontinued", format: "booleanFormat" },
];

const orderItemsFields: AgFieldDefinition[] = [
  { id: "order_item_id", name: "Order Item ID", format: "textFormat" },
  { id: "order_id", name: "Order ID", format: "textFormat" },
  { id: "product_id", name: "Product ID", format: "textFormat" },
  { id: "quantity", name: "Qty", format: "integerFormat" },
  { id: "unit_price", name: "Unit Price", format: "currencyFormat" },
  { id: "discount_pct", name: "Discount", format: "percentageFormat" },
  { id: "tax_rate", name: "Tax Rate", format: "percentageFormat" },
  { id: "returned", name: "Returned", format: "booleanFormat" },
  { id: "return_reason", name: "Return Reason", format: "textFormat" },
];

const customersFields: AgFieldDefinition[] = [
  { id: "customer_id", name: "Customer ID", format: "textFormat" },
  { id: "customer_name", name: "Customer", format: "textFormat" },
  { id: "region", name: "Region", format: "textFormat" },
  { id: "segment", name: "Segment", format: "textFormat" },
  { id: "industry", name: "Industry", format: "textFormat" },
];

const ordersFields: AgFieldDefinition[] = [
  {
    id: "order_id",
    name: "Order ID",
    format: "textFormat",
    cardinality: "high",
    notBlank: true,
  },
  { id: "customer_id", name: "Customer ID", format: "textFormat" },
  { id: "order_datetime", name: "Order Date/Time", format: "dateTimeFormat" },
  { id: "channel", name: "Channel", format: "textFormat" },
  { id: "status", name: "Status", format: "textFormat" },
  { id: "payment_method", name: "Payment Method", format: "textFormat" },
  { id: "currency", name: "Currency", format: "textFormat" },
];

const expressions: AgExpressionFieldDefinition[] = [
  {
    id: "line_gross",
    name: "Line Gross",
    isMeasure: false,
    format: "currencyFormat",
    formatOptions: { format: "$#,##0.0,K" },
    expression: {
      operator: "multiply",
      inputs: [
        { id: "order_items.quantity" },
        { id: "order_items.unit_price" },
      ],
    },
  },
  {
    id: "margin",
    name: "Margin",
    isMeasure: false,
    format: "currencyFormat",
    expression: {
      operator: "subtract",
      inputs: [{ id: "products.list_price" }, { id: "order_items.unit_price" }],
    },
  },
  {
    id: "line_net",
    name: "Line Net",
    isMeasure: false,
    format: "currencyFormat",
    expression: {
      operator: "subtract",
      inputs: [
        {
          operator: "multiply",
          inputs: [
            { id: "order_items.quantity" },
            { id: "order_items.unit_price" },
          ],
        },
        {
          operator: "multiply",
          inputs: [
            {
              operator: "multiply",
              inputs: [
                { id: "order_items.quantity" },
                { id: "order_items.unit_price" },
              ],
            },
            { id: "order_items.discount_pct" },
          ],
        },
      ],
    },
  },
];

let mobileOverlay: AgPopupInstance<MobileOverlayParams> | undefined;

let dataSources: AgDataSourcesDefinition | undefined;

const VueExample = defineComponent({
  template: `
        <div style="height: 100%">
                <div style="display: flex; flex-direction: column; height: 100%">
      <div class="example-controls">
        <div class="controls-row">
          <button v-on:click="toggleMobileOverlay()">Toggle Mobile Overlay</button>
        </div>
      </div>
      <ag-studio
        style="width: 100%; height: 100%;"
        class="my-studio-container"
        @api-ready="onApiReady"
        :initialState="initialState"
        :mode="mode"
        :data="data"
        @state-updated="onStateUpdated"></ag-studio>
      </div>
        </div>
    `,
  components: {
    "ag-studio": AgStudio,
    MobileOverlay,
  },
  setup(props) {
    const studioApi = shallowRef<AgStudioApi | null>(null);
    const initialState = ref<AgReportState | AgReportHistory>({
      pages: [
        {
          id: "overview",
          widgets: {
            "kpi-gross-sales": {
              type: "value",
              dataMapping: {
                value: [{ id: "line_gross", aggregation: "sum" }],
              },
              format: {
                style: { label: { enabled: true, text: "Gross Sales" } },
              },
            },
            "kpi-order-count": {
              type: "value",
              dataMapping: {
                value: [{ id: "orders.order_id", aggregation: "countd" }],
              },
              format: {
                style: { label: { enabled: true, text: "Order Count" } },
              },
            },
            "kpi-avg-qty": {
              type: "value",
              dataMapping: {
                value: [{ id: "order_items.quantity", aggregation: "avg" }],
              },
              format: {
                style: { label: { enabled: true, text: "Avg Qty per Line" } },
              },
            },
            "sales-by-category": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "products.subcategory" }],
                valueKey: [{ id: "line_gross", aggregation: "sum" }],
              },
              sort: [
                {
                  field: { id: "line_gross", aggregation: "sum" },
                  direction: "desc",
                },
              ],
              format: {
                title: { enabled: true, text: "Gross Sales by Subcategory" },
              },
            },
            "sales-by-brand": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "products.brand" }],
                valueKey: [{ id: "line_gross", aggregation: "sum" }],
              },
              sort: [
                {
                  field: { id: "line_gross", aggregation: "sum" },
                  direction: "desc",
                },
              ],
              format: {
                title: { enabled: true, text: "Gross Sales by Brand" },
              },
            },
            "top-products": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "products.product_name" },
                  { id: "products.category" },
                  { id: "order_items.quantity", aggregation: "sum" },
                  { id: "line_gross", aggregation: "sum" },
                  { id: "margin", aggregation: "avg" },
                ],
              },
              sort: [
                {
                  field: { id: "line_gross", aggregation: "sum" },
                  direction: "desc",
                },
              ],
              format: { title: { enabled: true, text: "Top Products" } },
            },
          },
          widgetLayout: {
            "kpi-gross-sales": { xTrack: 0, yTrack: 0, xSpan: 8, ySpan: 6 },
            "kpi-order-count": { xTrack: 8, yTrack: 0, xSpan: 8, ySpan: 6 },
            "kpi-avg-qty": { xTrack: 16, yTrack: 0, xSpan: 8, ySpan: 6 },
            "sales-by-category": { xTrack: 0, yTrack: 6, xSpan: 12, ySpan: 16 },
            "sales-by-brand": { xTrack: 12, yTrack: 6, xSpan: 12, ySpan: 16 },
            "top-products": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
          },
        },
        {
          id: "detail",
          widgets: {
            "subcategory-filter": {
              type: "list-filter",
              dataMapping: {
                value: [{ id: "products.subcategory" }],
              },
              format: {
                title: { enabled: true, text: "Subcategory" },
              },
            },
            "order-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "products.product_name" },
                  { id: "products.subcategory" },
                  { id: "order_items.quantity" },
                  { id: "order_items.unit_price" },
                  { id: "line_gross" },
                  { id: "line_net" },
                ],
              },
              format: {
                title: { enabled: true, text: "Order Details" },
              },
            },
          },
          widgetLayout: {
            "subcategory-filter": { xTrack: 0, yTrack: 0, xSpan: 6, ySpan: 32 },
            "order-grid": { xTrack: 6, yTrack: 0, xSpan: 18, ySpan: 32 },
          },
        },
        {
          id: "customers",
          widgets: {
            "kpi-unique-customers": {
              type: "value",
              dataMapping: {
                value: [{ id: "customers.customer_id", aggregation: "countd" }],
              },
              format: {
                style: { label: { enabled: true, text: "Unique Customers" } },
              },
            },
            "revenue-by-region": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "customers.region" }],
                valueKey: [{ id: "line_net", aggregation: "sum" }],
              },
              sort: [
                {
                  field: { id: "line_net", aggregation: "sum" },
                  direction: "desc",
                },
              ],
              format: { title: { enabled: true, text: "Revenue by Region" } },
            },
            "customer-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "customers.customer_name" },
                  { id: "customers.region" },
                  { id: "customers.segment" },
                  { id: "customers.industry" },
                  { id: "orders.order_id", aggregation: "countd" },
                  { id: "line_net", aggregation: "sum" },
                ],
              },
              sort: [
                {
                  field: { id: "line_net", aggregation: "sum" },
                  direction: "desc",
                },
              ],
              format: { title: { enabled: true, text: "Customer Details" } },
            },
          },
          widgetLayout: {
            "kpi-unique-customers": {
              xTrack: 0,
              yTrack: 0,
              xSpan: 24,
              ySpan: 6,
            },
            "revenue-by-region": { xTrack: 0, yTrack: 6, xSpan: 24, ySpan: 16 },
            "customer-grid": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
          },
        },
      ],
      selectedPageId: "overview",
      panels: {
        filters: {
          collapsed: true,
        },
        edit: {
          collapsed: true,
        },
        data: {
          collapsed: true,
        },
      },
    });
    const mode = ref<AgStudioMode>("edit");
    const data = ref<AgDataSourcesDefinition | AgDataEngine>(null);

    function onStateUpdated(e) {
      mobileOverlay?.refresh({ state: e.state, data: dataSources });
    }
    function toggleMobileOverlay() {
      if (mobileOverlay != null) {
        mobileOverlay.closePopup();
      } else {
        mobileOverlay = studioApi.value.createPopup<MobileOverlayParams>({
          comp: MobileOverlay,
          compParams: { state: studioApi.value.getState(), data: dataSources },
          title: "Preview",
          resizable: true,
          movable: true,
          width: 300,
          height: 400,
          minWidth: 300,
          closedCallback: () => {
            mobileOverlay = undefined;
          },
        });
      }
    }
    const onApiReady = (params: AgStudioApiReadyEvent) => {
      studioApi.value = params.api;

      Promise.all([
        loadJson("products.json"),
        loadJson("order_items.json"),
        loadJson("customers.json"),
        loadJson("orders.json"),
      ]).then(([productData, orderItemData, customerData, orderData]) => {
        dataSources = {
          sources: [
            {
              id: "products",
              name: "Products",
              data: productData,
              fields: productsFields,
            },
            {
              id: "order_items",
              name: "Order Items",
              data: orderItemData,
              fields: orderItemsFields,
            },
            {
              id: "customers",
              name: "Customers",
              data: customerData,
              fields: customersFields,
            },
            {
              id: "orders",
              name: "Orders",
              data: orderData,
              fields: ordersFields,
            },
          ],
          relationships: [
            {
              id: "order-item-product",
              source: "order_items.product_id",
              target: "products.product_id",
              type: "many-to-one",
              acceptFanout: true,
            },
            {
              id: "order-item-order",
              source: "order_items.order_id",
              target: "orders.order_id",
              type: "many-to-one",
            },
            {
              id: "order-customer",
              source: "orders.customer_id",
              target: "customers.customer_id",
              type: "many-to-one",
            },
          ],
          expressions,
        };
        params.api.setProperty("data", dataSources);
        // A preview opened while the data was loading has none, so give it the data now.
        mobileOverlay?.refresh({
          state: params.api.getState(),
          data: dataSources,
        });
      });
    };

    return {
      studioApi,
      initialState,
      mode,
      data,
      onApiReady,
      onStateUpdated,
      toggleMobileOverlay,
    };
  },
});

const app = createApp(VueExample);
app.mount("#app");
```

[Live example: Mobile Overlay](https://www.ag-grid.com/studio/examples/responsive-layout/mobile-overlay/vue3/)

The popup contains a second Studio in view mode, with a breakpoint that uses `singleColumnLayout`. The main Studio's `onStateUpdated` event passes each change to the preview with `setState`. The preview sets the `--ag-studio-min-width` and `--ag-studio-min-height` CSS variables to `0px` on its container, because it would otherwise inherit the main Studio's minimum size and could not shrink to the width of the popup.

## Limitations

- Responsive layouts are view only. In edit mode, Studio always shows and edits the desktop layout.
- Widget order follows the widget's position in the desktop layout. To change the order of widgets in a mobile layout, rearrange the desktop layout, or write a custom `layout` function.
