Docs

Grid

Vaadin Grid is a component for displaying tabular data, including various enhancements to grid renderings.

Vaadin Grid is a component for displaying tabular data, including various enhancements to grid renderings.

In the first example here, you can see a very basic example of a grid. A user can’t do much with it — other than scroll through the rows of data. However, Vaadin Grid has many features that may be enabled to allow the user to do plenty more. It can display the data in better ways, depending on its dynamic content.

The following sections describe these features. They include functioning examples of each feature on which you may try them: You can drag and drop, resort, and edit data.

Open in a
new tab
const [items, setItems] = useState<Person[]>([]);
useEffect(() => {
  getPeople().then(({ people }) => setItems(people));
}, []);

return (
  <Grid items={items}>
    <GridColumn path="firstName" />
    <GridColumn path="lastName" />
    <GridColumn path="email" />
    <GridColumn path="profession" />
  </Grid>
);

Content

A basic grid uses plain text to display information in rows and columns. Rich content can be included to provide additional information for more legibility. Components such as Text Field, Date Picker, Select, and Button are also supported.

Notice how much nicer and richer the grid content is rendered in the table below compared to the first example:

Open in a
new tab
const employeeRenderer = (person: Person) => (
  <HorizontalLayout style={{ alignItems: 'center' }} theme="spacing">
    <Avatar
      img={person.pictureUrl}
      name={`${person.firstName} ${person.lastName}`}
      // alt="User avatar"
    />

    <VerticalLayout style={{ lineHeight: 'var(--lumo-line-height-m)' }}>
      <span>
        {person.firstName} {person.lastName}
      </span>
      <span
        style={{ fontSize: 'var(--lumo-font-size-s)', color: 'var(--lumo-secondary-text-color)' }}
      >
        {person.email}
      </span>
    </VerticalLayout>
  </HorizontalLayout>
);

const statusRenderer = (person: Person) => (
  <span
    {...({
      theme: `badge ${person.status === 'Available' ? 'success' : 'error'}`,
    } satisfies object)}
  >
    {person.status}
  </span>
);

function Example() {
  const gridRef = React.useRef<any>(null);
  const [items, setItems] = useState<Person[]>([]);
  useEffect(() => {
    getPeople().then(({ people }) => setItems(people));

    // Workaround for https://github.com/vaadin/react-components/issues/129
    setTimeout(() => {
      gridRef.current?.recalculateColumnWidths();
    }, 100);
  }, []);

  return (
    <Grid items={items} ref={gridRef}>
      <GridSelectionColumn />

      <GridColumn header="Employee" flexGrow={0} autoWidth>
        {({ item }) => employeeRenderer(item)}
      </GridColumn>

      <GridColumn path="profession" autoWidth />

      <GridColumn header="Status" autoWidth>
        {({ item }) => statusRenderer(item)}
      </GridColumn>
    </Grid>
  );
}

Wrap Cell Content

Cell content that overflows is normally clipped or truncated. However, the wrap-cell-content variant makes the content wrap instead.

Notice in the example here that the text in the Address cells is wrapped. If you click on the gray icon at the top right corner of the example, it opens the table in a separate browser tab. When you do that, can see the addresses on one line. You can also resize that window to see how it wraps and unwraps the text.

Open in a
new tab
const [items, setItems] = useState<Person[]>([]);
useEffect(() => {
  getPeople().then(({ people }) => setItems(people));
}, []);

return (
  <Grid items={items} theme="wrap-cell-content">
    <GridColumn header="Image" flexGrow={0} autoWidth>
      {({ item: person }) => (
        <Avatar img={person.pictureUrl} name={`${person.firstName} ${person.lastName}`} />
      )}
    </GridColumn>
    <GridColumn path="firstName" />
    <GridColumn path="lastName" />
    <GridColumn header="Address">
      {({ item: { address } }) => (
        <span>
          {address.street} {address.city} {address.zip} {address.state}
        </span>
      )}
    </GridColumn>
  </Grid>
);

Tooltips can also be used to display content that doesn’t fit into the cell.

Dynamic Height

Grid has a default height of 400 pixels. It becomes scrollable when items contained in it overflow the allocated space.

In addition to setting any fixed or relative value, the height of a grid can be set by the number of items in the dataset. The grid expands and retracts based on the row count. This feature disables scrolling. It shouldn’t be used with large data sets since it might cause performance issues.

Notice how the height of the rows in the earlier example adjusts because of the text in the Address cells wrapping. With that in mind, click the gray icon at the top right corner of the example below to open it in a new browser tab. Try resizing it, making it narrower and then wider. Notice how the rows are always the same height and that the text doesn’t wrap. Instead, the text is truncated with ellipses.

Open in a
new tab
<Grid items={invitedPeople} allRowsVisible>
  <GridColumn header="Name" path="displayName" autoWidth />
  <GridColumn path="email" />
  <GridColumn path="address.phone" />
  <GridColumn header="Manage">
    {({ item: person }) => (
      <Button
        theme="error tertiary icon"
        onClick={() => {
          setInvitedPeople(invitedPeople.filter((p) => p.id !== person.id));
        }}
      >
        <Icon icon="vaadin:trash" />
      </Button>
    )}
  </GridColumn>
</Grid>

Selection

Selection isn’t enabled by default. Grid supports single- and multi-select. Single-select allows the user to select only one item, while multi-select permits multiple items to be selected.

Single-Selection Mode

In single-selection mode, the user can select and deselect rows by clicking anywhere on the row.

Open in a
new tab
const [items, setItems] = useState<Person[]>([]);
const [selectedItems, setSelectedItems] = useState<Person[]>([]);

useEffect(() => {
  getPeople().then(({ people }) => setItems(people));
}, []);

return (
  <Grid
    items={items}
    selectedItems={selectedItems}
    onActiveItemChanged={(e) => {
      const item = e.detail.value;
      setSelectedItems(item ? [item] : []);
    }}
  >
    <GridColumn path="firstName" />
    <GridColumn path="lastName" />
    <GridColumn path="email" />
  </Grid>
);

Multi-Select Mode

In multi-select mode, the user can use a checkbox column to select and deselect more than one row — not necessarily contiguous rows. Or the user can select all rows by clicking on the checkbox in the header row — and then un-check the ones they don’t want to be selected, rather than check many, individually.

Open in a
new tab
const [items, setItems] = useState<Person[]>([]);

useEffect(() => {
  getPeople().then(({ people }) => setItems(people));
}, []);

return (
  <Grid items={items}>
    <GridSelectionColumn />
    <GridColumn path="firstName" />
    <GridColumn path="lastName" />
    <GridColumn path="email" />
  </Grid>
);

Columns

Column alignment, freezing (i.e., fixed positioning), grouping, headers and footers, visibility, and width can be configured. Users can be allowed to resize and reorder columns.

Column Alignment

Three different column alignments are supported: left, which is the default; center; and right.

Right alignment is useful when comparing numeric values, as it helps with readability and scannability. Notice how the Amount column in the example here aligns the euro values to the right. Those values would be difficult to scan visually if they were left aligned or centered. Tabular numbers — if the font offers them — or a monospace font could be used to further improve digit alignment.

Open in a
new tab
<Grid items={items}>
  <GridColumn path="displayName" header="Name" />
  <GridColumn header="Due">{() => <span>{randomDate()}</span>}</GridColumn>
  <GridColumn header="Amount" textAlign="end">
    {() => <span style={{ fontVariantNumeric: 'tabular-nums' }}>{randomAmount()}</span>}
  </GridColumn>
</Grid>

Column Freezing

Columns and column groups can be frozen — made sticky — to exclude them from horizontally scrolling a grid. This can be useful for keeping the most important columns always visible in a grid that contains so many columns they might otherwise scroll out of view. Freezing columns at the end of the grid is useful, for example, for keeping row actions always visible.

In the example here, try scrolling the data to the right and back left. Notice that the name of each person is stationary, so that you can see easily which data relates to which person as you scroll. The Edit button at the end also remains in place so that it’s available at any point while scrolling.

Open in a
new tab
<GridColumn frozen header="Name" autoWidth flexGrow={0}>
  {({ item: person }) => (
    <>
      {person.firstName} {person.lastName}
    </>
  )}
</GridColumn>

<GridColumn frozenToEnd autoWidth flexGrow={0}>
  {() => <Button theme="tertiary-inline">Edit</Button>}
</GridColumn>

Although it’s technically possible to freeze any column, it should be used primarily to freeze columns at the start or end of the grid, leaving the remaining columns unfrozen.

Column Grouping

It’s possible to group columns together. They share a common header and footer. Use this feature to better visualize and organize related or hierarchical data.

In the example below, the first and last names are grouped under Name. Whereas, the street address, the city, and so forth are grouped under Address.

Open in a
new tab
const [items, setItems] = useState<Person[]>([]);

useEffect(() => {
  getPeople().then(({ people }) => setItems(people));
}, []);

return (
  <Grid items={items}>
    <GridColumnGroup header="Name">
      <GridColumn path="firstName" />
      <GridColumn path="lastName" />
    </GridColumnGroup>

    <GridColumnGroup header="Address">
      <GridColumn path="address.street" />
      <GridColumn path="address.city" />
      <GridColumn path="address.zip" />
      <GridColumn path="address.state" />
    </GridColumnGroup>
  </Grid>
);

Column Headers & Footers

Each column has a customizable header and footer. A basic column header shows the name in plain text. Footers are empty by default and therefore hidden. However, if you add content for the footer, it becomes visible. Incidentally, both the header and footer can contain rich content and components — notice the header and footer in the example here.

Open in a
new tab
function subscriberHeaderRenderer() {
  return (
    <HorizontalLayout style={{ alignItems: 'center' }}>
      <span>Subscriber</span>
      <Icon
        icon="vaadin:info-circle"
        title="Subscribers are paying customers"
        style={{ height: 'var(--lumo-font-size-m)', color: 'var(--lumo-contrast-70pct)' }}
      />
    </HorizontalLayout>
  );
}

function membershipHeaderRenderer() {
  return (
    <HorizontalLayout style={{ alignItems: 'center' }}>
      <span>Membership</span>
      <Icon
        icon="vaadin:info-circle"
        title="Membership levels determines which features a client has access to"
        style={{ height: 'var(--lumo-font-size-m)', color: 'var(--lumo-contrast-70pct)' }}
      />
    </HorizontalLayout>
  );
}

function Example() {
  const [items, setItems] = useState<Person[]>([]);

  useEffect(() => {
    getPeople().then(({ people }) =>
      setItems(
        people.map((person) => ({
          ...person,
          displayName: `${person.firstName} ${person.lastName}`,
        }))
      )
    );
  }, []);

  return (
    <Grid items={items}>
      <GridColumn
        path="displayName"
        header="Name"
        footerRenderer={() => <span>200 total members</span>}
      />

      <GridColumn
        headerRenderer={subscriberHeaderRenderer}
        footerRenderer={() => <span>102 subscribers</span>}
      >
        {({ item }) => <span>{item.subscriber ? 'Yes' : 'No'}</span>}
      </GridColumn>

      <GridColumn
        path="membership"
        headerRenderer={membershipHeaderRenderer}
        footerRenderer={() => <span>103 regular, 71 premium , 66 VIP</span>}
      />
    </Grid>
  );
}

Column Visibility

When you want, columns and column groups can be hidden. You can provide the user with a menu for toggling column visibilities, for example, using Menu Bar. Allowing the user to hide columns is useful when only a subset of the columns is relevant to their task, and if there are plenty of columns.

In the example here, notice that the email address and telephone numbers are not fully visible because the data doesn’t wrap and won’t fit in the width provided. Now click on the Show/Hide Columns button to see a menu of choices to reduce the number of columns. Notice that all columns are checked. Deselect the First Name and then the Profession column. That should allow the email addresses and telephone numbers to be fully visible. Incidentally, if you don’t deselect any columns, you can still right-click on an email address to copy it: You’ll still get the whole address, even if it’s not visible.

Open in a
new tab
const [items, setItems] = useState<Person[]>([]);

const [contextMenuItems, setContextMenuItems] = useState([
  { text: 'First name', checked: true, key: 'firstName' },
  { text: 'Last name', checked: true, key: 'lastName' },
  { text: 'Email', checked: true, key: 'email' },
  { text: 'Phone', checked: true, key: 'address.phone' },
  { text: 'Profession', checked: true, key: 'profession' },
]);

useEffect(() => {
  getPeople().then(({ people }) => setItems(people));
}, []);

return (
  <>
    <HorizontalLayout style={{ alignItems: 'baseline' }}>
      <strong style={{ flex: 1 }}>Employees</strong>
      <ContextMenu
        openOn="click"
        items={contextMenuItems}
        onItemSelected={(e: CustomEvent) => {
          const value = e.detail.value;
          setContextMenuItems(
            contextMenuItems.map((item) =>
              item.key === value.key ? { ...item, checked: !value.checked } : item
            )
          );
        }}
      >
        <Button theme="tertiary">Show/Hide Columns</Button>
      </ContextMenu>
    </HorizontalLayout>

    <Grid items={items}>
      {contextMenuItems.map((item) => (
        <GridColumn path={item.key} hidden={!item.checked} key={item.key} />
      ))}
    </Grid>
  </>
);

Column Reordering & Resizing

Enabling the user to reorder columns is useful when they want to compare data that isn’t adjacent by default. Grouped columns can only be reordered within their group. Resizing is helpful when a column’s content doesn’t fit and is cut off or varies in length.

Instead of hiding columns as in the earlier example, in the example here the user can resize a truncated column to be able to read it, fully. Try doing that. Hover your mouse pointer over the space on the header row between the Street and City columns, until the mouse pointer changes to a resizing tool. Then, while holding the left mouse button, pull on the right edge of the Street column until you can see all of the street addresses.

Open in a
new tab
const [items, setItems] = useState<Person[]>([]);
useEffect(() => {
  getPeople().then(({ people }) => setItems(people));
}, []);

return (
  <Grid items={items} columnReorderingAllowed>
    <GridColumnGroup header="Name">
      <GridColumn path="firstName" resizable />
      <GridColumn path="lastName" resizable />
    </GridColumnGroup>

    <GridColumnGroup header="Address">
      <GridColumn path="address.street" resizable />
      <GridColumn path="address.city" resizable />
      <GridColumn path="address.zip" resizable />
      <GridColumn path="address.state" resizable />
    </GridColumnGroup>
  </Grid>
);

Column Width

Unless specified, all columns are the same width. You can set a specific width for any column, though, or allow the Grid to set the width based on the content. Column widths can be fixed or non-fixed, which is the default. Fixed-width columns don’t grow or shrink as the available space changes, while non-fixed-width columns do.

In the following example, the first and last columns have fixed widths. The second column’s width is set to be based on the content, while the third column takes up the remaining space.

Open in a
new tab
function Example() {
  const [items, setItems] = useState<Person[]>([]);
  useEffect(() => {
    getPeople().then(({ people }) =>
      setItems(
        people.map((person) => ({
          ...person,
          displayName: `${person.firstName} ${person.lastName}`,
        }))
      )
    );
  }, []);

  return (
    <SplitLayout>
      <Grid items={items} style={{ width: '100%' }}>
        <GridSelectionColumn />
        <GridColumn path="firstName" width="7em" flexGrow={0} />
        <GridColumn path="profession" autoWidth flexGrow={0} />
        <GridColumn path="email" />
        <GridColumn width="6em" flexGrow={0} header="Has Sub">
          {({ item }) => (item.subscriber ? 'Yes' : 'No')}
        </GridColumn>
      </Grid>
      <div></div>
    </SplitLayout>
  );
}

Sorting

Any column can be used for sorting the data displayed. Enable sorting to allow the user to sort items alphabetically, numerically, by date, or by some other method.

Notice in the example below that each column heading has an up-down arrowhead icon. Click on one of the column headings and notice how the icon is changed to an up arrowhead, and the data in the table resorts alphanumerically in ascending order. Click the same heading again and the arrowhead is flipped and the data resorts in descending order. Do the same for another column.

Open in a
new tab
<Grid items={items}>
  <GridSortColumn path="id" />
  <GridSortColumn header="Name" path="displayName" />
  <GridSortColumn path="email" />
  <GridSortColumn path="profession" />
  <GridSortColumn path="birthday" />
</Grid>

Sorting by Multiple Columns

The Grid can be sorted by multiple columns if multi-sort mode is enabled. When it is enabled, clicking a column header adds that column as a sort criterion instead of replacing the current sort column. A separate multi-sort on shift-click mode combines the two by multi-sorting only when the column header is clicked while holding the Shift key. The order in which multi-sort criteria (i.e., the columns selected) are evaluated is determined by the multi-sort priority setting.

In the example here, click on the Profession column heading to set the first sort criterion. Then click on the Name column heading to set it as the second. Notice that when you select the second column heading, the number 2 is displayed next to it, and 1 next to the first column heading you selected. Notice that the data is sorted first by the profession and then by the full names — not by the last names. As a result, you can see a list of all of the Allergists at the top, sorted by their names. Those results are in ascending order. If you click on the Profession heading again, it sorts the professions in descending order, resulting in Urologists at the top. The names are still in descending order, unless you click that column heading, too.

Open in a
new tab
<Grid items={items} multiSort multiSortPriority="append">
  <GridSortColumn path="id" />
  <GridSortColumn path="displayName" header="Name" />
  <GridSortColumn path="email" />
  <GridSortColumn path="profession" />
  <GridSortColumn path="birthday" />
</Grid>
Note
Shift-Click Multi-Sorting Accessibility Issues
The multi-sort on shift-click mode is not recommended for applications for which accessibility is important. This feature is unlikely to work well with assistive technologies, and the lack of visual affordance makes it difficult to discover for sighted users.

Specifying Sort Property

Columns with rich or custom content can be sorted by defining the property by which to sort. For example, in the table here there’s a column containing the employees' first and last names, avatar images, and email addresses. By clicking on the heading for that column, it’ll sort the data by their last names.

Open in a
new tab
function employeeRenderer({ item: person }: { item: Person }) {
  return (
    <HorizontalLayout style={{ alignItems: 'center' }} theme="spacing">
      <Avatar
        img={person.pictureUrl}
        name={`${person.firstName} ${person.lastName}`}
        {...{ alt: 'User avatar' }}
      />

      <VerticalLayout style={{ lineHeight: 'var(--lumo-line-height-m)' }}>
        <span>
          {person.firstName} {person.lastName}
        </span>
        <span
          style={{ fontSize: 'var(--lumo-font-size-s)', color: 'var(--lumo-secondary-text-color)' }}
        >
          {person.email}
        </span>
      </VerticalLayout>
    </HorizontalLayout>
  );
}

function birthdayRenderer({ item: person }: { item: Person }) {
  const birthday = parseISO(person.birthday);
  return (
    <VerticalLayout style={{ lineHeight: 'var(--lumo-line-height-m)' }}>
      <span>{format(birthday, 'P')}</span>
      <span
        style={{ fontSize: 'var(--lumo-font-size-s)', color: 'var(--lumo-secondary-text-color)' }}
      >
        Age: {Math.floor((Date.now() - birthday.getTime()) / (1000 * 60 * 60 * 24 * 365.25))}
      </span>
    </VerticalLayout>
  );
}

function Example() {
  const [items, setItems] = useState<Person[]>([]);

  useEffect(() => {
    getPeople().then(({ people }) => setItems(people));
  }, []);

  return (
    <Grid items={items}>
      <GridSortColumn header="Employee" path="lastName">
        {employeeRenderer}
      </GridSortColumn>

      <GridSortColumn header="Birthdate" path="birthday">
        {birthdayRenderer}
      </GridSortColumn>
    </Grid>
  );
}

Sorting helps users find and examine data. Therefore, it’s recommended to enable sorting for all applicable columns. An exception, though, would be when the order is an essential part of the data itself, such as with prioritized lists.

Filtering

Filtering allows the user to find a specific item or subset of items. You can add filters to Grid columns or use external filter fields.

For instance, try typing anna in the input box for Name below. When you’re finished, the data shown is only people who have anna in their name. That includes some with the names Anna and Annabelle, as well as some with Arianna and Brianna.

Open in a
new tab
function Example() {
  const [items, setItems] = useState<PersonEnhanced[]>([]);

  useEffect(() => {
    getPeople().then(({ people }) =>
      setItems(
        people.map((person) => ({
          ...person,
          displayName: `${person.firstName} ${person.lastName}`,
        }))
      )
    );
  }, []);

  return (
    <>
      <Grid items={items}>
        <GridFilterColumn header="Name" path="displayName" flexGrow={0} width="230px">
          {nameRenderer}
        </GridFilterColumn>

        <GridFilterColumn path="email" />
        <GridFilterColumn path="profession" />
      </Grid>
    </>
  );
}

Place filters outside the grid when the filter is based on multiple columns, or when a bigger field or more complex filter UI is needed, one which wouldn’t fit well in a column. In the example here, whatever you type in the search box can be matched against all of the columns. Type Rheumatologist in the search box. The results show only the rows with that profession.

Open in a
new tab
function nameRenderer(person: PersonEnhanced) {
  return (
    <HorizontalLayout style={{ alignItems: 'center' }} theme="spacing">
      <Avatar img={person.pictureUrl} name={person.displayName}></Avatar>
      <span> {person.displayName} </span>
    </HorizontalLayout>
  );
}

function Example() {
  const [filteredItems, setFilteredItems] = useState<PersonEnhanced[]>([]);
  const [items, setItems] = useState<PersonEnhanced[]>([]);

  useEffect(() => {
    getPeople().then(({ people }) => {
      const newItems = people.map((person) => ({
        ...person,
        displayName: `${person.firstName} ${person.lastName}`,
      }));
      setItems(newItems);
      setFilteredItems(newItems);
    });
  }, []);

  return (
    <>
      <VerticalLayout theme="spacing">
        <TextField
          placeholder="Search"
          style={{ width: '50%' }}
          onValueChanged={(e) => {
            const searchTerm = (e.detail.value || '').trim().toLowerCase();
            setFilteredItems(
              items.filter(
                ({ displayName, email, profession }) =>
                  !searchTerm ||
                  displayName.toLowerCase().includes(searchTerm) ||
                  email.toLowerCase().includes(searchTerm) ||
                  profession.toLowerCase().includes(searchTerm)
              )
            );
          }}
        >
          <Icon slot="prefix" icon="vaadin:search"></Icon>
        </TextField>

        <Grid items={filteredItems}>
          <GridColumn header="Name" flexGrow={0} width="230px">
            {({ item }) => nameRenderer(item)}
          </GridColumn>

          <GridColumn path="email"></GridColumn>
          <GridColumn path="profession"></GridColumn>
        </Grid>
      </VerticalLayout>
    </>
  );
}

Lazy Loading

When you want to display a list of items that would be quite large to load entirely into memory, or you want to load items from a database, data providers can be used to provide lazy loading through pagination.

The following example works like the earlier example, but it uses a data provider for lazy loading, sorting, and filtering items.

Open in a
new tab
async function fetchPeople(params: {
  page: number;
  pageSize: number;
  searchTerm: string;
  sortOrders: GridSorterDefinition[];
}) {
  const { page, pageSize, searchTerm, sortOrders } = params;
  const { people } = await getPeople();
  let result = people.map((person) => ({
    ...person,
    fullName: `${person.firstName} ${person.lastName}`,
  }));

  // Filtering
  if (searchTerm) {
    result = result.filter(
      (p) => matchesTerm(p.fullName, searchTerm) || matchesTerm(p.profession, searchTerm)
    );
  }

  // Sorting
  const sortBy = Object.fromEntries(sortOrders.map(({ path, direction }) => [path, direction]));
  if (sortBy.fullName) {
    result = result.sort((p1, p2) => compare(p1.fullName, p2.fullName, sortBy.fullName));
  } else if (sortBy.profession) {
    result = result.sort((p1, p2) => compare(p1.profession, p2.profession, sortBy.profession));
  }

  // Pagination
  const count = result.length;
  result = result.slice(page * pageSize, pageSize);

  return { people: result, count };
}

function Example() {
  const [searchTerm, setSearchTerm] = useState('');

  const dataProvider = useMemo(
    () =>
      async (
        params: GridDataProviderParams<Person>,
        callback: GridDataProviderCallback<Person>
      ) => {
        const { page, pageSize, sortOrders } = params;

        const { people, count } = await fetchPeople({
          page,
          pageSize,
          sortOrders,
          searchTerm,
        });

        callback(people, count);
      },
    [searchTerm]
  );

  return (
    <VerticalLayout theme="spacing">
      <TextField
        placeholder="Search"
        style={{ width: '50%' }}
        onValueChanged={(e) => {
          setSearchTerm(e.detail.value.trim());
        }}
      >
        <Icon slot="prefix" icon="vaadin:search" />
      </TextField>

      <Grid dataProvider={dataProvider}>
        <GridSortColumn path="fullName" header="Name" />
        <GridSortColumn path="profession" />
      </Grid>
    </VerticalLayout>
  );

Lazy Column Rendering

Grids containing a large number of columns can sometimes exhibit performance issues. If many of the columns are typically outside the visible viewport, rendering performance can be optimized by using "lazy column rendering" mode.

This mode enables virtual scrolling horizontally. It renders body cells only when their corresponding columns are inside the visible viewport.

Lazy rendering should be used only with a large number of columns and performance is a high priority. For most use cases, though, the default "eager" mode is recommended.

When considering whether to use the "lazy" mode, keep the following factors in mind:

Row Height

When only a number of columns are visible at once, the height of a row can only be that of the highest cell currently visible on that row. Make sure each cell on a single row has the same height as all of the other cells on the row. Otherwise, users may notice jumpiness when horizontally scrolling the grid as lazily rendered cells with different heights are scrolled into view.

Auto-Width Columns

For columns that are initially outside the visible viewport, but still use auto-width, only the header content is taken into account when calculating the column width. This is because the body cells of the columns outside the viewport are not rendered initially.

Screen Reader Compatibility

Screen readers may not be able to associate the focused cells with the correct headers when only a subset of the body cells on a row is rendered.

Keyboard Navigation

Tabbing through focusable elements inside the grid body may not work as expected. This is because some of the columns that would include focusable elements in the body cells may be outside the visible viewport and thus not rendered.

No Improvement If All Columns Visible

The lazy column rendering mode can only improve the rendering performance when a significant portion of the columns are outside of the Grid’s visible viewport. It has no effect on Grids in which all columns are visible without horizontal scrolling.

Open in a
new tab
const [items, setItems] = useState<Person[]>([]);
useEffect(() => {
  getPeople().then(({ people }) => setItems(people));
}, []);

return (
  <Grid items={items} columnRendering="lazy">
    <GridColumn frozen>{({ model }) => indexColumnRenderer(model.index)}</GridColumn>

    {[...Array(100).keys()].map((index) => (
      // Generate 100 columns
      <GridColumn data-index={index} header={`Col ${index}`}>
        {({ original, model }) => columnRenderer(original, model.index)}
      </GridColumn>
    ))}
  </Grid>
);

Item Details

Item details are expandable content areas that can be displayed below the regular content of a row. They can be used to display more information about an item. By default, an item’s details are toggled by clicking on the item’s row. Try clicking on one of the rows in the example here. Notice that when you do, the row is expanded to show the person’s email address, telephone number, and home address. If you click on the row again, it’s collapsed back to a single line.

Open in a
new tab
const [items, setItems] = useState<Person[]>([]);
const [detailsOpenedItem, setDetailsOpenedItem] = useState<Person[]>([]);

useEffect(() => {
  getPeople().then(({ people }) => {
    setItems(
      people.map((person) => ({
        ...person,
        displayName: `${person.firstName} ${person.lastName}`,
      }))
    );
  });
}, []);

return (
  <Grid
    theme="row-stripes"
    items={items}
    detailsOpenedItems={detailsOpenedItem}
    onActiveItemChanged={(event) => {
      const person = event.detail.value;
      setDetailsOpenedItem(person ? [person] : []);
    }}
    rowDetailsRenderer={({ item: person }) => (
      <FormLayout responsiveSteps={[{ minWidth: '0', columns: 3 }]}>
        <TextField label="Email address" value={person.email} {...{ colspan: 3 }} readonly />
        <TextField
          label="Phone number"
          value={person.address.phone}
          {...{ colspan: 3 }}
          readonly
        />
        <TextField
          label="Street address"
          value={person.address.street}
          {...{ colspan: 3 }}
          readonly
        />
        <TextField label="ZIP code" value={person.address.zip} readonly />
        <TextField label="City" value={person.address.city} readonly />
        <TextField label="State" value={person.address.state} readonly />
      </FormLayout>
    )}
  >
    <GridColumn path="displayName" header="Name" />
    <GridColumn path="profession" />
  </Grid>
);

The default toggle behavior can be replaced by programmatically toggling the details visibility, such as from a button click.

Open in a
new tab
const [items, setItems] = useState<Person[]>([]);
const [detailsOpenedItems, setDetailsOpenedItems] = useState<Person[]>([]);

useEffect(() => {
  getPeople().then(({ people }) =>
    setItems(
      people.map((person) => ({
        ...person,
        displayName: `${person.firstName} ${person.lastName}`,
      }))
    )
  );
}, []);

return (
  <Grid
    theme="row-stripes"
    items={items}
    detailsOpenedItems={detailsOpenedItems}
    rowDetailsRenderer={({ item: person }) => (
      <FormLayout responsiveSteps={[{ minWidth: '0', columns: 3 }]}>
        <TextField label="Email address" value={person.email} {...{ colspan: 3 }} readonly />
        <TextField
          label="Phone number"
          value={person.address.phone}
          {...{ colspan: 3 }}
          readonly
        />
        <TextField
          label="Street address"
          value={person.address.street}
          {...{ colspan: 3 }}
          readonly
        />
        <TextField label="ZIP code" value={person.address.zip} readonly />
        <TextField label="City" value={person.address.city} readonly />
        <TextField label="State" value={person.address.state} readonly />
      </FormLayout>
    )}
  >
    <GridColumn path="displayName" header="Name" />
    <GridColumn path="profession" />
    <GridColumn>
      {({ item: person }) => (
        <Button
          theme="tertiary"
          onClick={() => {
            const isOpened = detailsOpenedItems.includes(person);
            setDetailsOpenedItems(
              isOpened
                ? detailsOpenedItems.filter((p) => p !== person)
                : [...detailsOpenedItems, person]
            );
          }}
        >
          Toggle details
        </Button>
      )}
    </GridColumn>
  </Grid>
);

Tooltips can be used as a lightweight alternative to the item details panel.

Context Menu

You can use Context Menu to provide shortcuts for the user. It appears on a right-click by default. In a mobile browser, a long press opens the menu. In the example here, try right-clicking on one of the rows. You’ll notice a box appears with a list of choices: Edit the row, delete it, email the person, or call them. If this example were fully configured, the latter two would open the related application (i.e., the default email program or a telephone application).

Using a context menu shouldn’t be the only way of accomplishing a task, though. The same functionality needs to be accessible elsewhere in the UI. See the documentation page on Context Menu for more information.

Open in a
new tab
const renderMenu = ({
  context,
}: Readonly<{
  context: ContextMenuRendererContext;
  original: ContextMenuElement;
}>) => {
  if (!gridRef.current) {
    return null;
  }

  const { sourceEvent } = context.detail as { sourceEvent: Event };
  const grid = gridRef.current;

  const eventContext = grid.getEventContext(sourceEvent);
  const person = eventContext.item;

  const clickHandler = (action: string) => () => {
    console.log(`${action}: ${person.firstName} ${person.lastName}`);
  };

  return (
    <ListBox>
      <Item onClick={clickHandler('Edit')}>Edit</Item>
      <Item onClick={clickHandler('Delete')}>Delete</Item>
      <hr />
      <Item onClick={clickHandler('Email')}>Email ({person.email})</Item>
      <Item onClick={clickHandler('Call')}>Call ({person.address.phone})</Item>
    </ListBox>
  );
};

return (
  <ContextMenu renderer={renderMenu}>
    <Grid items={items} ref={gridRef}>
      <GridColumn path="firstName" />
      <GridColumn path="lastName" />
      <GridColumn path="email" />
      <GridColumn path="profession" />
    </Grid>
  </ContextMenu>
);

Tooltips

Tooltips on cells can be useful in many situations: They can be used to give more details on the contents of a cell — if an item details panel would be overkill or otherwise undesirable. They can show the full text of a cell if it’s too long to fit feasibly into the cell itself — if wrapping the cell contents is insufficient or otherwise undesirable. Or they can give textual explanations for non-text content, such as status icons.

In the example here, hold your mouse pointer over the birthday date for one of the rows. A tooltip should appear indicating the age of the person. Now hover over one of the status icons, an X or a checkmark. It’ll use Tooltips to interpret the meaning of the icons.

Open in a
new tab
const tooltipGenerator = (context: GridEventContext<Person>): string => {
  let text = '';

  const { column, item } = context;
  if (column && item) {
    switch (column.path) {
      case 'birthday':
        text = `Age: ${differenceInYears(Date.now(), parseISO(item.birthday))}`;
        break;
      case 'status':
        text = item.status;
        break;
      default:
        break;
    }
  }

  return text;
};

return (
  <Grid items={items}>
    <GridColumn path="firstName" />
    <GridColumn path="lastName" />
    <GridColumn path="birthday" />
    <GridColumn path="status" footerRenderer={() => null} headerRenderer={() => null}>
      {statusRenderer}
    </GridColumn>
    <Tooltip slot="tooltip" generator={tooltipGenerator} />
  </Grid>
);

See the Tooltips documentation page for details on tooltip configuration.

Drag & Drop

Grid supports drag-and-drop actions. This feature might be used, for example, to reorder rows and to drag rows between grids.

Drop Mode

The drop mode of a grid determines where a drop can happen. Vaadin offers four different drop modes, which are described in the table here:

Drop Mode Description

On Grid

Drops can occur on the grid as a whole, not on top of rows or between individual rows. Use this mode when the order isn’t important.

Between

Drops can happen between rows. Use this mode when the order is important.

On Top

Drops can take place on top of rows. This is useful when creating relationships between items or moving an item into another item, such as placing a file inside a folder.

On Top or Between

Drops can occur on top of rows or between them.

Row Reordering

You can drag rows to reorder them. This can be a useful and impressive feature for users. Try dragging with your mouse one of the rows of data in the example here to another place in the list.

Open in a
new tab
function handleDragStart(event: GridDragStartEvent<Person>): void {
  setDraggedItem(event.detail.draggedItems[0]);
}

function handleDragEnd(): void {
  setDraggedItem(undefined);
}

function handleDrop(event: GridDropEvent<Person>): void {
  const { dropTargetItem, dropLocation } = event.detail;
  // Only act when dropping on another item
  if (draggedItem && dropTargetItem !== draggedItem) {
    // Remove the item from its previous position
    const draggedItemIndex = items.indexOf(draggedItem);
    items.splice(draggedItemIndex, 1);
    // Re-insert the item at its new position
    const dropIndex = items.indexOf(dropTargetItem) + (dropLocation === 'below' ? 1 : 0);
    items.splice(dropIndex, 0, draggedItem);
    // Re-assign the array to refresh the grid
    setItems([...items]);
  }
}

return (
  <Grid
    items={items}
    rowsDraggable
    dropMode="between"
    onGridDragstart={handleDragStart}
    onGridDragend={handleDragEnd}
    onGridDrop={handleDrop}
  >
    <GridColumn header="Image" flexGrow={0} autoWidth>
      {({ item: person }) => (
        <Avatar
          img={person.pictureUrl}
          name={`${person.firstName} ${person.lastName}`}
          {...{ alt: 'User avatar' }}
        />
      )}
    </GridColumn>

    <GridColumn path="firstName" />
    <GridColumn path="lastName" />
    <GridColumn path="email" />
  </Grid>
);

Drag Rows between Grids

Rows can be dragged from one grid to another. You might use this feature to move, copy or link items from different datasets.

In the example here, there are two grids of data. Maybe they represent people to speak at two different presentations at the same conference. One grid lists the first panel of speakers and the other the second panel. Try dragging people from one to the other, as if you were reassigning them to speak at a different panel.

Open in a
new tab
const startDraggingItem = (event: GridDragStartEvent<Person>) => {
  setDraggedItem(event.detail.draggedItems[0]);
};

const clearDraggedItem = () => {
  setDraggedItem(undefined);
};

return (
  <div style={gridContainerStyle}>
    <Grid
      items={grid1Items}
      rowsDraggable
      dropMode="on-grid"
      style={gridStyle}
      onGridDragstart={startDraggingItem}
      onGridDragend={clearDraggedItem}
      onGridDrop={() => {
        const draggedPerson = draggedItem!;
        const draggedItemIndex = grid2Items.indexOf(draggedPerson);
        if (draggedItemIndex >= 0) {
          const updatedGrid2Items = grid2Items.filter((_, index) => index !== draggedItemIndex);
          setGrid2Items(updatedGrid2Items);
          setGrid1Items([...grid1Items, draggedPerson]);
        }
      }}
    >
      <GridColumn header="Full name">
        {({ item: person }) => `${person.firstName} ${person.lastName}`}
      </GridColumn>

      <GridColumn path="profession" />
    </Grid>

    <Grid
      items={grid2Items}
      rowsDraggable
      dropMode="on-grid"
      style={gridStyle}
      onGridDragstart={startDraggingItem}
      onGridDragend={clearDraggedItem}
      onGridDrop={() => {
        const draggedPerson = draggedItem!;
        const draggedItemIndex = grid1Items.indexOf(draggedPerson);
        if (draggedItemIndex >= 0) {
          const updatedGrid1Items = grid1Items.filter((_, index) => index !== draggedItemIndex);
          setGrid1Items(updatedGrid1Items);
          setGrid2Items([...grid2Items, draggedPerson]);
        }
      }}
    >
      <GridColumn header="Full name">
        {({ item: person }) => `${person.firstName} ${person.lastName}`}
      </GridColumn>

      <GridColumn path="profession" />
    </Grid>
  </div>
);

Drag & Drop Filters

Drag-and-drop filters determine which rows are draggable and which rows are valid drop targets. These filters function on a per-row basis.

Open in a
new tab
const [draggedItem, setDraggedItem] = useState<Person>();
const [items, setItems] = useState<Person[]>([]);
const [expandedItems, setExpandedItems] = useState<Person[]>([]);

useEffect(() => {
  getPeople().then(({ people }) => setItems(people));
}, []);

const dataProvider = useMemo(
  () => (params: GridDataProviderParams<Person>, callback: GridDataProviderCallback<Person>) => {
    const { page, pageSize, parentItem } = params;
    const startIndex = page * pageSize;
    const endIndex = startIndex + pageSize;

    /*
  We cannot change the underlying data in this demo so this dataProvider uses
  a local field to fetch its values. This allows us to keep a reference to the
  modified list instead of loading a new list every time the dataProvider gets
  called. In a real application, you should always access your data source
  here and avoid using grid.clearCache() whenever possible.
  */
    const result = parentItem
      ? items.filter((item) => item.managerId === parentItem.id)
      : items.filter((item) => item.manager).slice(startIndex, endIndex);

    callback(result, result.length);
  },
  [items]
);

return (
  <Grid
    dataProvider={dataProvider}
    itemIdPath="id"
    itemHasChildrenPath="manager"
    expandedItems={expandedItems}
    onExpandedItemsChanged={(event) => {
      setExpandedItems(event.detail.value);
    }}
    rowsDraggable
    dropMode="on-top"
    onGridDragstart={(event) => {
      setDraggedItem(event.detail.draggedItems[0]);
    }}
    onGridDragend={() => {
      setDraggedItem(undefined);
    }}
    onGridDrop={(event) => {
      const manager = event.detail.dropTargetItem;
      if (draggedItem) {
        draggedItem.managerId = manager.id;
        setItems([...items]);
      }
    }}
    dragFilter={(model) => {
      const item = model.item;
      return !item.manager;
    }}
    dropFilter={(model) => {
      const item = model.item;
      return item.manager && item.id !== draggedItem?.managerId;
    }}
  >
    <GridTreeColumn path="firstName" />
    <GridColumn path="lastName" />
    <GridColumn path="email" />
  </Grid>
);

Styling Rows & Columns Dynamically

Cells can be styled dynamically, based on application logic and the data in the grid, through custom part names. This can be used, for example, to highlight specific rows and apply custom styling to specific columns.

In the example below, bold font weight is applied to the Rating column with a font-weight-bold part name, and the rows are colored red and green, based on their rating, with low-rating and high-rating part names, respectively. The styling itself is applied in a stylesheet with vaadin-grid::part() selectors (e.g., vaadin-grid::part(high-rating)).

Open in a
new tab
interface PersonWithRating extends Person {
  customerRating: number;
}

const cellPartNameGenerator: GridCellPartNameGenerator<PersonWithRating> = (column, model) => {
  const item = model.item;
  let parts = '';
  // Make the customer rating column bold
  if (column.header?.startsWith('Customer rating')) {
    parts += ' font-weight-bold';
  }
  // Add high-rating part to customer ratings of 8 or higher
  if (item.customerRating >= 8.0) {
    parts += ' high-rating';
    // Add low-rating part to customer ratings of 4 or lower
  } else if (item.customerRating <= 4.0) {
    parts += ' low-rating';
  }
  return parts;
};

const ratingFormatter = new Intl.NumberFormat('en-US', {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
});

const ratingRenderer = (person: PersonWithRating) => (
  <span>${ratingFormatter.format(person.customerRating)}</span>
);

function Example() {
  const [items, setItems] = useState<PersonWithRating[]>([]);
  useEffect(() => {
    getPeople().then(({ people }) => {
      const peopleWithRating = people.map((person) => ({
        ...person,
        customerRating: Math.random() * 10,
      }));
      setItems(peopleWithRating);
    });
  }, []);

  return (
    <Grid items={items} cellPartNameGenerator={cellPartNameGenerator}>
      <GridColumn path="firstName" />
      <GridColumn path="lastName" />
      <GridColumn path="profession" />
      <GridColumn header="Customer rating (0-10)">{({ item }) => ratingRenderer(item)}</GridColumn>
    </Grid>
  );
}

Theme Variants

Grid variants can reduce the white space inside the grid, adjust the border and row to highlight visibility, and control cell content overflow behavior. They’re versatile and can be combined.

Compact

The compact theme variant makes a grid denser by reducing the header and row heights, as well as the spacing between columns.

This is useful for displaying more information on-screen without having to scroll. It can also help improve scannability and comparability between rows. Notice that there are more rows displayed in the example here, compared to the number of rows visible in the same space in the earlier example.

Open in a
new tab
<Grid items={items} theme="compact">
  <GridColumn path="firstName" />
  <GridColumn path="lastName" />
  <GridColumn path="email" />
</Grid>

No Border

The no-border theme variant removes the outer border of the grid. Compare the example here with the previous one. Notice that the outer border, surrounding all of the rows is missing here.

Open in a
new tab
<Grid items={items} theme="no-border">
  <GridColumn header="Image" flexGrow={0} autoWidth>
    {({ item }) => (
      <Avatar
        img={item.pictureUrl}
        name={`${item.firstName} ${item.lastName}`}
        {...{ alt: 'User avatar' }}
      />
    )}
  </GridColumn>
  <GridColumn path="firstName" />
  <GridColumn path="lastName" />
  <GridColumn path="email" />
</Grid>

No Row Border

This theme variant removes the horizontal row borders. This is best suited for small datasets. Viewing larger datasets may be difficult unless paired with the row-stripes theme variant. You can see this in the example here. There’s no border between the rows. With so much space, notice how it’s a little difficult to be sure you’re reading the email address of a particular person — and not the email address of a different row. It would be worse with a wider table containing many columns of data.

Open in a
new tab
<Grid items={items} theme="no-row-borders">
  <GridColumn header="Image" flexGrow={0} autoWidth>
    {({ item }) => (
      <Avatar
        img={item.pictureUrl}
        name={`${item.firstName} ${item.lastName}`}
        {...{ alt: 'User avatar' }}
      />
    )}
  </GridColumn>
  <GridColumn path="firstName" />
  <GridColumn path="lastName" />
  <GridColumn path="email" />
</Grid>

Column Borders

You can add vertical borders between columns by using the column-borders theme variant. Datasets with a lot of cramped columns, or where content is truncated, can benefit from the extra separation that vertical borders bring. Compare the table here to previous ones. You can see that this one has a border between each column. While this can sometimes make reading the data easier, it can be aesthetically displeasing — look too rigid.

Open in a
new tab
<Grid items={items} theme="column-borders">
  <GridColumn header="Image" flexGrow={0} autoWidth>
    {({ item }) => (
      <Avatar
        img={item.pictureUrl}
        name={`${item.firstName} ${item.lastName}`}
        {...{ alt: 'User avatar' }}
      />
    )}
  </GridColumn>
  <GridColumn path="firstName" />
  <GridColumn path="lastName" />
  <GridColumn path="email" />
</Grid>

Row Stripes

The row-stripes theme variant produces a background color for every other row. This can make scanning the rows of data easier. You can see in the example here that the odd rows have a light gray background, while the even ones have none or a white background. This is particularly useful with very wide tables, with many columns of data.

Open in a
new tab
<Grid items={items} theme="row-stripes">
  <GridColumn header="Image" flexGrow={0} autoWidth>
    {({ item }) => (
      <Avatar
        img={item.pictureUrl}
        name={`${item.firstName} ${item.lastName}`}
        {...{ alt: 'User avatar' }}
      />
    )}
  </GridColumn>
  <GridColumn path="firstName" />
  <GridColumn path="lastName" />
  <GridColumn path="email" />
</Grid>

Cell Focus

Many of the explanations and examples above alluded to giving the focus to rows and cells. Cells can be focused by clicking on a cell, or with the keyboard. The following keyboard shortcuts are available with Grid:

Keys Action

Tab

Switches focus between sections of the grid (i.e., header, body, footer).

Left, Up, Right, and Down Arrow Keys

Moves focus between cells within a section of the grid.

Page Up

Moves cell focus up by one page of visible rows.

Page Down

Moves cell focus down by one page of visible rows.

Home

Moves focus to the first cell in a row.

End

Moves focus to the last cell in a row.

The cell focus event can be used to be notified when the user changes focus between cells. By default, the focus outline is only visible when using keyboard navigation. For illustrative purposes, the example below also uses custom styles to show the focus outline when clicking on cells. Try clicking on a cell. Notice how the cell is highlighted and notice the information shown at the bottom, the information provided about the event.

Open in a
new tab
const handleCellFocus = (event: GridCellFocusEvent<Person>) => {
  if (!gridRef.current) {
    return;
  }
  const eventContext = gridRef.current.getEventContext(event);
  const section = eventContext.section ?? 'Not available';
  const row = eventContext.index != null ? eventContext.index : 'Not available';
  const column = eventContext.column?.path ?? 'Not available';
  const person = eventContext.item;
  const fullName =
    person?.firstName && person?.lastName
      ? `${person.firstName} ${person.lastName}`
      : 'Not available';

  setEventSummary(`Section: ${section}\nRow: ${row}\nColumn: ${column}\nPerson: ${fullName}`);
};

return (
  <>
    <Grid
      className="force-focus-outline"
      items={items}
      onCellFocus={handleCellFocus}
      ref={gridRef}
    >
      <GridColumn path="firstName" />
      <GridColumn path="lastName" />
      <GridColumn path="email" />
      <GridColumn path="profession" />
    </Grid>

    <div>
      <TextArea
        label="Cell focus event information"
        readonly
        value={eventSummary}
        style={{ width: '100%' }}
      />
    </div>
  </>
);
Component Usage Recommendation

CRUD

Component for creating, displaying, updating, and deleting tabular data.

Grid Pro

Component for showing and editing tabular data.

Tree Grid

Component for showing hierarchical tabular data.

List Box

Lightweight component for lightweight, single-column lists.