Data Grid

Frozen Edges

A wide grid with both edge columns frozen: the row label on the left and the owner on the right stay put while the weeks scroll between them.

  • datagrid
  • sticky-column
  • scroll
  • table

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/datagrid-003?lang=en

Поставки по регионам

Scroll sideways: the edge columns are frozen

Orders by week, units
РегионНед. 1Нед. 2Нед. 3Нед. 4Нед. 5Нед. 6Нед. 7Нед. 8Менеджер
Москва412388455501478522590544Орлова
Петербург280305291318344330352371Ким
Казань141158162149173181176194Гнедин
Новосибирск98112121108133127140152Савва
Екатеринбург120118131145139151148163Лебедь
Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "datagrid-003" (Frozen Edges) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/datagrid-003.json

Registry item: https://vibeui.ru/r/datagrid-003.json
Installs to: components/vibeui/datagrid-003.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A wide grid with both edge columns frozen: the row label on the left and the owner on the right stay put while the weeks scroll between them.

A wide grid with two frozen columns and horizontal scrolling that works from the keyboard. Server component, zero dependencies, one file.

## 3. How to use it
import { Datagrid003 } from "@/components/vibeui/datagrid-003"

<Datagrid003
  caption="Orders by week, units"
  scrollHint="Scroll sideways: the edge columns are frozen"
/>

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-datagrid-003-* palette — do not swap it for your theme tokens
- the sticky cells' own background: without it the scrolling rows show straight through
- border-collapse:separate — with collapsed borders a sticky column loses its own line
- the scroll container with role=region, aria-label and tabindex=0: otherwise only a mouse can pan it
- the sticky header's z-index above the sticky columns, or they cover the heading
- the grid's own light surface: on the dark catalog card dark text disappears without it

## 6. You may change
- the line under the toolbar through caption
- the scrolling hint through scrollHint
- the column names through weeks and the data through rows
- the accent color through accent

## 7. Rules
- Two sticky columns eat width: on a 360 px screen little room is left between them, so check it.
- The edge shadow appears on scroll only where scroll-timeline is supported; elsewhere there simply is none.
- The table min-width is set in rem: with five columns lower it, otherwise the table scrolls for nothing.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/datagrid-003.json
https://vibeui.ru/r/datagrid-003.json

Компонент самодостаточен: один файл, без зависимостей, палитра в --vibeui-datagrid-003-*. Серверный: состояния нет, всё держится на CSS. Крайние колонки закреплены position:sticky с left:0 и right:0 и несут собственный фон — без него уезжающие ячейки видно насквозь. Таблица собрана с border-collapse:separate: со схлопнутыми границами липкая ячейка теряет свою линию. Прокрутка живёт в контейнере с role=region, aria-label и tabindex=0, поэтому работает с клавиатуры. Тень у закреплённых краёв появляется по прокрутке через animation-timeline:scroll под @supports — там, где его нет, тени просто нет, и вёрстка не страдает. Максимум недели помечен пилюлей на color-mix от акцента.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Datagrid003Row = {
  region: string
  weeks: number[]
  owner: string
}

export type Datagrid003Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children"
> & {
  rows?: Datagrid003Row[]
  weeks?: string[]
  caption?: string
  scrollHint?: string
  accent?: string
}

// Идея компонента: широкая сетка, у которой закреплены обе крайние колонки —
// подпись строки слева и колонка действия справа. Между ними уезжают недели.
// Прокрутка живёт в собственном контейнере с tabindex и подписью, поэтому
// она работает с клавиатуры, а не только колесом. Тень у закреплённых колонок
// появляется по прокрутке через scroll-timeline там, где он поддержан.
const STYLES = `
:where([data-vibeui-block="datagrid-003"]){
--vibeui-datagrid-003-bg:oklch(1 0 0);
--vibeui-datagrid-003-fg:oklch(0.23 0.014 250);
--vibeui-datagrid-003-muted:oklch(0.55 0.014 250);
--vibeui-datagrid-003-border:oklch(0.92 0.006 250);
--vibeui-datagrid-003-head:oklch(0.975 0.003 250);
--vibeui-datagrid-003-accent:oklch(0.52 0.14 200);
--vibeui-datagrid-003-shadow:oklch(0.23 0.014 250 / 18%);
--vibeui-datagrid-003-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="datagrid-003"]{
box-sizing:border-box;width:100%;max-width:60rem;margin:0 auto;
background:var(--vibeui-datagrid-003-bg);color:var(--vibeui-datagrid-003-fg);
border:1px solid var(--vibeui-datagrid-003-border);border-radius:0.875rem;
font-family:var(--vibeui-datagrid-003-font);overflow:hidden;
}
[data-vibeui-block="datagrid-003"] *{box-sizing:border-box}
[data-vibeui-block="datagrid-003"] [data-part="head"]{
display:flex;flex-wrap:wrap;align-items:baseline;gap:0.5rem;
padding:0.875rem;border-bottom:1px solid var(--vibeui-datagrid-003-border);
}
[data-vibeui-block="datagrid-003"] [data-part="title"]{margin:0;font-size:0.875rem;font-weight:650}
[data-vibeui-block="datagrid-003"] [data-part="hint"]{
margin:0;font-size:0.75rem;color:var(--vibeui-datagrid-003-muted);
}
[data-vibeui-block="datagrid-003"] [data-part="scroll"]{overflow-x:auto}
[data-vibeui-block="datagrid-003"] [data-part="scroll"]:focus-visible{outline:2px solid var(--vibeui-datagrid-003-accent);outline-offset:-2px}
/* separate, а не collapse: со схлопнутыми границами липкая ячейка теряет
   свою линию, и колонка выглядит оторванной от таблицы. */
[data-vibeui-block="datagrid-003"] table{
width:100%;min-width:52rem;border-collapse:separate;border-spacing:0;font-size:0.8125rem;
}
[data-vibeui-block="datagrid-003"] caption{
padding:0 0.875rem 0.625rem;text-align:left;
font-size:0.75rem;color:var(--vibeui-datagrid-003-muted);
}
[data-vibeui-block="datagrid-003"] th,
[data-vibeui-block="datagrid-003"] td{
padding:0.5rem 0.875rem;text-align:left;white-space:nowrap;
border-bottom:1px solid var(--vibeui-datagrid-003-border);
background:var(--vibeui-datagrid-003-bg);
}
[data-vibeui-block="datagrid-003"] thead th{background:var(--vibeui-datagrid-003-head);font-weight:600}
[data-vibeui-block="datagrid-003"] tbody tr:last-child th,
[data-vibeui-block="datagrid-003"] tbody tr:last-child td{border-bottom:0}
[data-vibeui-block="datagrid-003"] [data-align="end"]{text-align:right;font-variant-numeric:tabular-nums}
/* Закреплённые края. Фон обязателен: без него уезжающие ячейки видно
   насквозь. */
[data-vibeui-block="datagrid-003"] [data-part="lead"]{
position:sticky;left:0;z-index:2;min-width:9rem;
border-right:1px solid var(--vibeui-datagrid-003-border);
}
[data-vibeui-block="datagrid-003"] [data-part="trail"]{
position:sticky;right:0;z-index:2;text-align:right;
border-left:1px solid var(--vibeui-datagrid-003-border);
}
[data-vibeui-block="datagrid-003"] thead [data-part="lead"],
[data-vibeui-block="datagrid-003"] thead [data-part="trail"]{z-index:3;background:var(--vibeui-datagrid-003-head)}
[data-vibeui-block="datagrid-003"] [data-part="owner"]{color:var(--vibeui-datagrid-003-muted)}
[data-vibeui-block="datagrid-003"] [data-part="peak"]{
display:inline-block;padding:0.0625rem 0.375rem;border-radius:0.375rem;
color:var(--vibeui-datagrid-003-accent);
background:color-mix(in oklab, var(--vibeui-datagrid-003-accent) 12%, transparent);
font-weight:600;
}
@supports (animation-timeline: scroll(nearest inline)){
[data-vibeui-block="datagrid-003"] [data-part="lead"]{
animation:vibeui-datagrid-003-lead linear both;
animation-timeline:scroll(nearest inline);animation-range:0 2rem;
}
[data-vibeui-block="datagrid-003"] [data-part="trail"]{
animation:vibeui-datagrid-003-trail linear both;
animation-timeline:scroll(nearest inline);animation-range:calc(100% - 2rem) 100%;
}
@keyframes vibeui-datagrid-003-lead{
from{clip-path:inset(0 -1rem 0 0);box-shadow:0 0 0 transparent}
to{clip-path:inset(0 -1rem 0 0);box-shadow:0.5rem 0 0.75rem -0.5rem var(--vibeui-datagrid-003-shadow)}
}
@keyframes vibeui-datagrid-003-trail{
from{clip-path:inset(0 0 0 -1rem);box-shadow:-0.5rem 0 0.75rem -0.5rem var(--vibeui-datagrid-003-shadow)}
to{clip-path:inset(0 0 0 -1rem);box-shadow:0 0 0 transparent}
}
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="datagrid-003"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_WEEKS = [
  "Нед. 1",
  "Нед. 2",
  "Нед. 3",
  "Нед. 4",
  "Нед. 5",
  "Нед. 6",
  "Нед. 7",
  "Нед. 8",
]

const DEFAULT_ROWS: Datagrid003Row[] = [
  {
    region: "Москва",
    weeks: [412, 388, 455, 501, 478, 522, 590, 544],
    owner: "Орлова",
  },
  {
    region: "Петербург",
    weeks: [280, 305, 291, 318, 344, 330, 352, 371],
    owner: "Ким",
  },
  {
    region: "Казань",
    weeks: [141, 158, 162, 149, 173, 181, 176, 194],
    owner: "Гнедин",
  },
  {
    region: "Новосибирск",
    weeks: [98, 112, 121, 108, 133, 127, 140, 152],
    owner: "Савва",
  },
  {
    region: "Екатеринбург",
    weeks: [120, 118, 131, 145, 139, 151, 148, 163],
    owner: "Лебедь",
  },
]

/**
 * Широкая сетка с закреплёнными крайними колонками: подпись строки слева,
 * действие справа, недели прокручиваются между ними. Серверный компонент.
 */
export function Datagrid003({
  rows = DEFAULT_ROWS,
  weeks = DEFAULT_WEEKS,
  caption = "Заказы по неделям, штук",
  scrollHint = "Прокрутите вбок: крайние колонки закреплены",
  accent,
  className,
  style,
  ...props
}: Datagrid003Props) {
  const palette = {
    ...(accent ? { "--vibeui-datagrid-003-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-datagrid-003" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="datagrid-003"
        className={className}
        style={palette}
      >
        <div data-part="head">
          <h3 data-part="title">Поставки по регионам</h3>
          <p data-part="hint">{scrollHint}</p>
        </div>
        <div
          data-part="scroll"
          role="region"
          aria-label="Таблица поставок по неделям, прокручивается вбок"
          tabIndex={0}
        >
          <table>
            <caption>{caption}</caption>
            <thead>
              <tr>
                <th scope="col" data-part="lead">
                  Регион
                </th>
                {weeks.map((week) => (
                  <th key={week} scope="col" data-align="end">
                    {week}
                  </th>
                ))}
                <th scope="col" data-part="trail">
                  Менеджер
                </th>
              </tr>
            </thead>
            <tbody>
              {rows.map((row) => {
                const peak = Math.max(...row.weeks)

                return (
                  <tr key={row.region}>
                    <th scope="row" data-part="lead">
                      {row.region}
                    </th>
                    {row.weeks.map((value, index) => (
                      <td key={weeks[index] ?? index} data-align="end">
                        {value === peak ? (
                          <span data-part="peak">{value}</span>
                        ) : (
                          value
                        )}
                      </td>
                    ))}
                    <td data-part="trail">
                      <span data-part="owner">{row.owner}</span>
                    </td>
                  </tr>
                )
              })}
            </tbody>
          </table>
        </div>
      </section>
    </>
  )
}