Building LitGrid: One Web Component DataGrid for React, Angular, Vue, and Blazor
TL;DR This is my first article about LitGrid, so I wanted to start with the architectural decision that shaped the project from the beginning. I started LitGrid with one rule for myself: Build the grid once as a real Web Component, keep its internals behind Shadow DOM, and adapt frameworks around that component instead of rebuilding the grid for each one. Today, React, Angular, Vue, Blazor, and direct Web Component usage all sit on top of the same LitGrid implementation. Behind the component, grid state and virtualization live in separate layers. Live demo: https://tipolox.com/litgrid/demo Source: https://github.com/tipolox/litgrid The part I found interesting was not simply building another table component. I was already working with Lit and Shadow DOM, and I kept coming back to the same question: could I keep that component boundary and still make the grid practical in several frameworks? The constraint that shaped everything When I started LitGrid, the idea was not to build a React grid, then an Angular grid, then repeat the work again for Vue and Blazor. I wanted one implementation. React ────────┐ Angular ──────┤ Vue ──────────┼──โบ LitGrid Web Component Blazor ───────┤ Web Component ┘ The idea was simple: the React package should not implement its own sorting, selection, virtualization, keyboard navigation, column resizing, or filtering. The same goes for Angular, Vue, and Blazor. Their job is to translate framework conventions into the public API of the same browser component. If I fix grid behavior in one place, I want all integration to benefit from it. This has led to this architecture: Framework adapters │ โผ Web Component │ LitElement Shadow DOM │ ┌────┴─────┐ │ │ Grid Core Renderer state/data virtualization React, Angular, Vue, and Blazor are not interchangeable, and I don't try to hide those differences. The part I want to keep shared is the grid implementation itself. Each adapter deals with framework ergonomics; the grid behavior stays in one place. Why Lit and Shadow DOM? Lit was part of the original direction, not something I added later to make the project framework-neutral. The main DataGrid class extends LitElement , and LitGrid keeps Lit's normal Shadow DOM render root instead of switching to light DOM. Conceptually: #shadow-root header viewport rows cells menus pagination That sounds like a small implementation choice, but it defines a lot of the architecture. Applications do not need to depend on internal selectors like: .viewport .header-cell .cell .header-menu Those details belong to LitGrid. Consumers work with the host element through public properties, methods, and events. Styling follows the same philosophy. Internal styles live inside the component, while semantic CSS custom properties provide the external theming surface. For example: yc-grid { --litgrid-color-accent: #2563eb; --litgrid-color-surface: #ffffff; --litgrid-color-text: #111827; } LitGrid currently uses CSS custom properties as its main public styling mechanism. It does not currently expose a broad ::part() styling API. That is intentional for now. Once an internal element becomes part of a public styling contract, changing the markup later gets harder. I would rather expose a smaller, deliberate surface first. Shadow DOM gave me the encapsulation I wanted. It also made a few problems impossible to ignore. What Shadow DOM made me handle Encapsulation is useful, but a DataGrid is not a static card component. It has focus, keyboard navigation, menus, selection, ARIA state, scrolling, drag interactions, and a lot of DOM coordination. Focus management LitGrid keeps its active-cell model internally and uses the grid viewport as the keyboard focus entry point. Arrow keys move the active cell. Home and End move across row boundaries. Page Up and Page Down move by viewport-sized ranges. Tab and Shift+Tab can move between grid cells while still allowing normal browser focus traversal when the grid boundary is reached. Header menus also need explicit focus behavior. When a menu opens, focus moves into it. When Escape closes the menu, focus returns to the trigger. This was one of the first places where encapsulation stopped being "free." A grid has to feel predictable from the keyboard, so I ended up treating focus movement as explicit component behavior and covering it with tests rather than assuming the browser would always do the right thing across the shadow boundary. ARIA relationships The viewport uses ARIA grid semantics, including logical row and column counts and an active descendant. One important detail is that the referenced elements are inside the same Shadow Root as the viewport. For example, the active-cell relationship stays internal to the component rather than trying to reference an element across the Shadow DOM boundary. That keeps the accessibility model aligned with the component structure. I also try to be careful with the accessibility claim. LitGrid implements ARIA grid semantics, keyboard interaction, and optional screen-reader announcements, and those structures have automated coverage. What I am not claiming is exhaustive validation across every browser and screen-reader combination. That kind of confidence needs broader real-world testing. A public API instead of DOM coupling The Web Component boundary only helps if the adapters respect it. For example, I don't want the React package reaching through the shadow root to find private elements: grid.shadowRoot ?.querySelector('.viewport') ?.something() That would work until the internal markup changed. Then a harmless refactor inside LitGrid could break a framework adapter. Instead, the host exposes operations directly: grid.data = rows grid.columns = columns grid.config = config grid.setColumnVisible('email', false) grid.setQuickSearch('active') grid.setPage(2) State changes can also be observed through DOM events. For example: new CustomEvent('column-state-change', { bubbles: true, composed: true, detail: { reason, state } }) Those events are dispatched from the custom-element host and translated by the framework adapters into the conventions expected by each framework. What a thin React adapter actually looks like This is also why I describe the adapters as thin. In the React integration, "thin" is fairly literal. The React integration ultimately renders the Web Component: return Complex values are assigned as JavaScript properties rather than serialized as HTML attributes: export function syncGridInputs(grid, inputs) { grid.data = inputs.data grid.columns = inputs.columns grid.config = inputs.config grid.theme = inputs.theme grid.ariaLabel = inputs.ariaLabel grid.ariaDescription = inputs.ariaDescription grid.viewportHeight = inputs.height grid.virtualRowHeight = inputs.rowHeight grid.overscanCount = inputs.overscan grid.columnOverscanCount = inputs.columnOverscan } That property assignment matters for Web Components. Things like arrays, column definitions, callbacks, and configuration objects are not naturally represented as string attributes. The adapter also listens to DOM events: useEffect(() => { const element = gridRef.current if (!element || !onColumnStateChange) { return } const handler = event => { onColumnStateChange(event.detail) } element.addEventListener('column-state-change', handler) return () => { element.removeEventListener('column-state-change', handler) } }, [onColumnStateChange]) And then React developers use a normal React-facing API: That is most of the job: translate React-shaped inputs and callbacks into the Web Component contract. Sorting, selection, virtualization, and the rest still belong to LitGrid itself. The Web Component isn't the whole architecture As the grid grew, another problem became obvious: putting every piece of logic inside one large LitElement would eventually make the component difficult to reason about. So I split responsibilities instead of treating the Web Component as the entire architecture. Grid Core The core handles state and data operations such as: - sorting - filtering - Quick Search - pagination - selection - configuration It does not need the DOM to perform those operations. That makes the logic easier to test independently from the component UI and keeps browser-rendering concerns out of the data engine. Renderer utilities The renderer layer contains virtualization and scroll-mapping calculations. That math should not care whether the caller happens to be React, Angular, Vue, or Blazor. Keeping it separate also makes it easier to test without dragging the whole component lifecycle into the test. Web Component The Web Component combines those layers with: - DOM rendering - pointer interaction - focus - keyboard navigation - menus - sizing - scrolling - ARIA behavior That separation gives the project a useful rule of thumb: Data/state problem ↓ Core Virtual-coordinate problem ↓ Renderer Browser interaction/rendering ↓ Web Component Framework convention ↓ Adapter The boundaries are not perfect, and I do not expect them to be. But when I add something new, this gives me a practical first question: is this grid state, virtualization math, browser behavior, or framework glue? Virtualization is more than "render fewer rows" The obvious reason to virtualize a grid is DOM size. If a logical dataset contains: 100,000 rows × 50 columns the grid should not create five million DOM cells. LitGrid instead renders the rows and columns that intersect the active viewport, plus overscan. Logical dataset │ │ not rendered │ ├─────────────────────── │ overscan │ ┌───────────────┐ │ │ viewport │ │ │ │ │ │ rendered rows │ │ │ and columns │ │ │ │ │ └───────────────┘ │ overscan ├─────────────────────── │ │ not rendered Rendering fewer rows was the obvious virtualization problem. The less obvious problem showed up when the logical scroll space became very large. A browser cannot represent an arbitrarily tall element forever. Eventually, a huge spacer can be clamped internally, which means the browser's physical scroll ran
Comments
No comments yet. Start the discussion.