Data Grid

Pin On Demand

A wide grid where pinning is the reader's call: a pin in the header makes the column sticky, and its offset is derived from the columns already pinned.

  • datagrid
  • sticky-column
  • pinning
  • 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-014?lang=en

Продажи по филиалам

Закреплено: 1 из 2

Press the pin in a header to freeze the column on the left
ГородРуководительПланФактСделокОтток, %NPS
КазаньЮсупова4 2004 4601184,162
НовосибирскЕрёмин3 9003 510966,848
Ростов-на-ДонуКовалёва3 1003 320873,471
ЕкатеринбургТагиров4 6004 1801315,255
КалининградШмидт1 8002 040542,974
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-014" (Pin On Demand) 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-014.json

Registry item: https://vibeui.ru/r/datagrid-014.json
Installs to: components/vibeui/datagrid-014.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 where pinning is the reader's call: a pin in the header makes the column sticky, and its offset is derived from the columns already pinned.

A grid with on-demand column pinning: a pin in the header switches the column to position:sticky. Zero dependencies, one file.

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

<Datagrid014 caption="Pin the columns you need" maxPinned={3} />

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-014-* palette — do not swap it for your theme tokens
- aria-pressed on the pin: it is a state toggle, not a plain action button
- the sticky cells' own background — without it the scrolling rows show straight through
- table-layout:fixed together with a fixed column width: otherwise there is nothing to compute the left offset from
- the pin order following the column order rather than the click order — otherwise columns swap places
- 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 cap on pinned columns through maxPinned
- the branch list through rows
- the accent color through accent

## 7. Rules
- Pinning works from the left only: the right edge needs a second offset variable and right instead of left.
- Every column shares one width: for mixed widths compute the offset as a sum rather than a multiplication.
- On a 360 px screen two pinned columns leave no room — lower maxPinned for mobile layouts.
- 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-014.json
https://vibeui.ru/r/datagrid-014.json

Компонент самодостаточен: один файл, без зависимостей, палитра в --vibeui-datagrid-014-*. Клиентский: useState хранит список закреплённых ключей, но порядок берётся из порядка колонок, а не кликов — иначе столбцы менялись бы местами под курсором. Ширина колонки фиксирована переменной --vibeui-datagrid-014-col вместе с table-layout:fixed: только так left считается формулой calc, без измерений в браузере. Липкая ячейка несёт собственный фон, таблица собрана с border-collapse:separate. Булавка — кнопка с aria-pressed и собственным aria-label, число закреплений ограничено пропом. Прокрутка живёт в контейнере с role=region, aria-label и tabindex=0.

Component source

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

"use client"

import { useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Datagrid014Row = {
  id: string
  city: string
  manager: string
  plan: number
  fact: number
  deals: number
  churn: number
  nps: number
}

export type Datagrid014Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children"
> & {
  rows?: Datagrid014Row[]
  caption?: string
  maxPinned?: number
  accent?: string
}

// Идея компонента: закрепление колонки — решение читателя, а не вёрстки.
// Кнопка-булавка в каждом заголовке переключает aria-pressed, а колонка
// получает position:sticky со смещением, посчитанным из ширин уже
// закреплённых соседей. Ширины фиксированы через table-layout:fixed —
// без этого смещение слева пришлось бы мерить в браузере.
const STYLES = `
:where([data-vibeui-block="datagrid-014"]){
--vibeui-datagrid-014-bg:oklch(1 0 0);
--vibeui-datagrid-014-fg:oklch(0.23 0.014 285);
--vibeui-datagrid-014-muted:oklch(0.55 0.014 285);
--vibeui-datagrid-014-border:oklch(0.92 0.006 285);
--vibeui-datagrid-014-head:oklch(0.975 0.003 285);
--vibeui-datagrid-014-accent:oklch(0.5 0.15 195);
--vibeui-datagrid-014-pinbg:oklch(0.97 0.02 195);
--vibeui-datagrid-014-shadow:oklch(0.23 0.014 285 / 12%);
--vibeui-datagrid-014-col:11rem;
--vibeui-datagrid-014-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="datagrid-014"]{
box-sizing:border-box;width:100%;max-width:56rem;margin:0 auto;
background:var(--vibeui-datagrid-014-bg);color:var(--vibeui-datagrid-014-fg);
border:1px solid var(--vibeui-datagrid-014-border);border-radius:0.875rem;
font-family:var(--vibeui-datagrid-014-font);overflow:hidden;
}
[data-vibeui-block="datagrid-014"] *{box-sizing:border-box}
[data-vibeui-block="datagrid-014"] [data-part="bar"]{
display:flex;flex-wrap:wrap;align-items:center;gap:0.5rem;
padding:0.75rem 0.875rem;border-bottom:1px solid var(--vibeui-datagrid-014-border);
}
[data-vibeui-block="datagrid-014"] [data-part="title"]{margin:0;font-size:0.875rem;font-weight:650;margin-inline-end:auto}
[data-vibeui-block="datagrid-014"] [data-part="state"]{margin:0;font-size:0.75rem;color:var(--vibeui-datagrid-014-muted)}
[data-vibeui-block="datagrid-014"] [data-part="scroll"]{overflow-x:auto}
[data-vibeui-block="datagrid-014"] [data-part="scroll"]:focus-visible{outline:2px solid var(--vibeui-datagrid-014-accent);outline-offset:-2px}
[data-vibeui-block="datagrid-014"] table{
border-collapse:separate;border-spacing:0;table-layout:fixed;
width:max-content;min-width:100%;font-size:0.8125rem;
}
[data-vibeui-block="datagrid-014"] caption{
padding:0.625rem 0.875rem;text-align:left;font-size:0.75rem;color:var(--vibeui-datagrid-014-muted);caption-side:top;
}
[data-vibeui-block="datagrid-014"] th,
[data-vibeui-block="datagrid-014"] td{
width:var(--vibeui-datagrid-014-col);padding:0.5rem 0.75rem;text-align:left;
border-bottom:1px solid var(--vibeui-datagrid-014-border);
background:var(--vibeui-datagrid-014-bg);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
[data-vibeui-block="datagrid-014"] thead th{
background:var(--vibeui-datagrid-014-head);font-weight:600;vertical-align:bottom;
position:sticky;top:0;z-index:2;
}
[data-vibeui-block="datagrid-014"] [data-align="end"]{text-align:right;font-variant-numeric:tabular-nums}
/* Закреплённая колонка обязана нести свой фон: иначе уезжающие ячейки видно насквозь. */
[data-vibeui-block="datagrid-014"] [data-pinned="true"]{
position:sticky;left:var(--vibeui-datagrid-014-offset,0);z-index:3;
background:var(--vibeui-datagrid-014-pinbg);
}
[data-vibeui-block="datagrid-014"] thead [data-pinned="true"]{z-index:4}
[data-vibeui-block="datagrid-014"] [data-edge="true"]{box-shadow:6px 0 10px -8px var(--vibeui-datagrid-014-shadow)}
[data-vibeui-block="datagrid-014"] [data-part="head-inner"]{display:flex;align-items:center;gap:0.375rem;justify-content:inherit}
[data-vibeui-block="datagrid-014"] [data-align="end"] [data-part="head-inner"]{justify-content:flex-end}
[data-vibeui-block="datagrid-014"] [data-part="pin"]{
appearance:none;cursor:pointer;font:inherit;line-height:1;flex:none;
width:1.375rem;height:1.375rem;border-radius:0.375rem;
border:1px solid transparent;background:transparent;color:var(--vibeui-datagrid-014-muted);
transition:color .15s ease,border-color .15s ease;
}
[data-vibeui-block="datagrid-014"] [data-part="pin"]:hover{color:var(--vibeui-datagrid-014-accent)}
[data-vibeui-block="datagrid-014"] [data-part="pin"][aria-pressed="true"]{
color:var(--vibeui-datagrid-014-accent);border-color:var(--vibeui-datagrid-014-accent);
}
[data-vibeui-block="datagrid-014"] [data-part="pin"]:disabled{opacity:.35;cursor:not-allowed}
[data-vibeui-block="datagrid-014"] [data-part="pin"]:focus-visible{outline:2px solid var(--vibeui-datagrid-014-accent);outline-offset:2px}
[data-vibeui-block="datagrid-014"] [data-part="clear"]{
appearance:none;cursor:pointer;font:inherit;font-size:0.75rem;
padding:0.3125rem 0.625rem;border-radius:0.5rem;
border:1px solid var(--vibeui-datagrid-014-border);
background:var(--vibeui-datagrid-014-bg);color:var(--vibeui-datagrid-014-fg);
}
[data-vibeui-block="datagrid-014"] [data-part="clear"]:disabled{opacity:.45;cursor:not-allowed}
[data-vibeui-block="datagrid-014"] [data-part="clear"]:focus-visible{outline:2px solid var(--vibeui-datagrid-014-accent);outline-offset:2px}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="datagrid-014"] *{animation:none!important;transition:none!important}}
`

type ColumnKey =
  "city" | "manager" | "plan" | "fact" | "deals" | "churn" | "nps"

const COLUMNS: { key: ColumnKey; label: string; numeric: boolean }[] = [
  { key: "city", label: "Город", numeric: false },
  { key: "manager", label: "Руководитель", numeric: false },
  { key: "plan", label: "План", numeric: true },
  { key: "fact", label: "Факт", numeric: true },
  { key: "deals", label: "Сделок", numeric: true },
  { key: "churn", label: "Отток, %", numeric: true },
  { key: "nps", label: "NPS", numeric: true },
]

const DEFAULT_ROWS: Datagrid014Row[] = [
  {
    id: "r1",
    city: "Казань",
    manager: "Юсупова",
    plan: 4200,
    fact: 4460,
    deals: 118,
    churn: 4.1,
    nps: 62,
  },
  {
    id: "r2",
    city: "Новосибирск",
    manager: "Ерёмин",
    plan: 3900,
    fact: 3510,
    deals: 96,
    churn: 6.8,
    nps: 48,
  },
  {
    id: "r3",
    city: "Ростов-на-Дону",
    manager: "Ковалёва",
    plan: 3100,
    fact: 3320,
    deals: 87,
    churn: 3.4,
    nps: 71,
  },
  {
    id: "r4",
    city: "Екатеринбург",
    manager: "Тагиров",
    plan: 4600,
    fact: 4180,
    deals: 131,
    churn: 5.2,
    nps: 55,
  },
  {
    id: "r5",
    city: "Калининград",
    manager: "Шмидт",
    plan: 1800,
    fact: 2040,
    deals: 54,
    churn: 2.9,
    nps: 74,
  },
]

function display(row: Datagrid014Row, key: ColumnKey) {
  if (key === "city" || key === "manager") {
    return row[key]
  }

  if (key === "churn") {
    return row.churn.toLocaleString("ru-RU", { minimumFractionDigits: 1 })
  }

  return row[key].toLocaleString("ru-RU")
}

/**
 * Сетка, где закрепить колонку решает читатель: булавка в заголовке
 * переводит столбец в position:sticky. Один файл, ноль зависимостей.
 */
export function Datagrid014({
  rows = DEFAULT_ROWS,
  caption = "Нажмите булавку в заголовке, чтобы закрепить колонку слева",
  maxPinned = 2,
  accent,
  className,
  style,
  ...props
}: Datagrid014Props) {
  const [pinned, setPinned] = useState<ColumnKey[]>(["city"])

  // Порядок закрепления берётся из порядка колонок, а не из порядка кликов:
  // иначе закреплённые столбцы менялись бы местами прямо под курсором.
  const ordered = COLUMNS.filter((column) => pinned.includes(column.key)).map(
    (column) => column.key,
  )

  const palette = {
    ...(accent ? { "--vibeui-datagrid-014-accent": accent } : null),
    ...style,
  } as CSSProperties

  function offsetFor(key: ColumnKey) {
    const index = ordered.indexOf(key)

    return index < 0
      ? undefined
      : ({
          "--vibeui-datagrid-014-offset": `calc(var(--vibeui-datagrid-014-col) * ${index})`,
        } as CSSProperties)
  }

  return (
    <>
      <style href="vibeui-datagrid-014" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="datagrid-014"
        className={className}
        style={palette}
      >
        <div data-part="bar">
          <h3 data-part="title">Продажи по филиалам</h3>
          <p data-part="state" aria-live="polite">
            {ordered.length === 0
              ? "Закреплённых колонок нет"
              : `Закреплено: ${ordered.length} из ${maxPinned}`}
          </p>
          <button
            type="button"
            data-part="clear"
            disabled={ordered.length === 0}
            onClick={() => setPinned([])}
          >
            Снять закрепление
          </button>
        </div>
        <div
          data-part="scroll"
          role="region"
          aria-label="Таблица филиалов, прокручивается вбок"
          tabIndex={0}
        >
          <table>
            <caption>{caption}</caption>
            <thead>
              <tr>
                {COLUMNS.map((column) => {
                  const isPinned = ordered.includes(column.key)
                  const isEdge =
                    isPinned && ordered[ordered.length - 1] === column.key

                  return (
                    <th
                      key={column.key}
                      scope="col"
                      data-align={column.numeric ? "end" : undefined}
                      data-pinned={isPinned ? "true" : undefined}
                      data-edge={isEdge ? "true" : undefined}
                      style={offsetFor(column.key)}
                    >
                      <span data-part="head-inner">
                        {column.label}
                        <button
                          type="button"
                          data-part="pin"
                          aria-pressed={isPinned}
                          aria-label={
                            isPinned
                              ? `Открепить колонку «${column.label}»`
                              : `Закрепить колонку «${column.label}» слева`
                          }
                          disabled={!isPinned && ordered.length >= maxPinned}
                          onClick={() =>
                            setPinned((current) =>
                              current.includes(column.key)
                                ? current.filter((key) => key !== column.key)
                                : [...current, column.key],
                            )
                          }
                        >
                          {isPinned ? "◆" : "◇"}
                        </button>
                      </span>
                    </th>
                  )
                })}
              </tr>
            </thead>
            <tbody>
              {rows.map((row) => (
                <tr key={row.id}>
                  {COLUMNS.map((column) => {
                    const isPinned = ordered.includes(column.key)
                    const isEdge =
                      isPinned && ordered[ordered.length - 1] === column.key
                    const content = display(row, column.key)

                    return column.key === "city" ? (
                      <th
                        key={column.key}
                        scope="row"
                        data-pinned={isPinned ? "true" : undefined}
                        data-edge={isEdge ? "true" : undefined}
                        style={offsetFor(column.key)}
                      >
                        {content}
                      </th>
                    ) : (
                      <td
                        key={column.key}
                        data-align={column.numeric ? "end" : undefined}
                        data-pinned={isPinned ? "true" : undefined}
                        data-edge={isEdge ? "true" : undefined}
                        style={offsetFor(column.key)}
                      >
                        {content}
                      </td>
                    )
                  })}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </section>
    </>
  )
}