Every sales rep on earth knows how to use Google Sheets. They know how to click a cell, type a value, and press Tab to move to the next one. They know how to freeze the first column so the contact name stays visible while they scroll right through dozens of fields. They know how to copy a block of cells and paste it somewhere else. This is not learned behavior for a specific application. It is muscle memory for an entire interaction paradigm.
Most CRMs ignore this. They give you forms. Click a contact, wait for a detail page to load, find the field, click edit, type the value, click save, go back to the list, repeat. For a rep updating 50 contacts after a call blitz, that workflow is agony. We decided early on that SalesSheet's contact grid would feel like a spreadsheet, not a database admin panel. This post explains how we built it.
The best CRM interface is the one your reps already know how to use. For most teams, that interface is a spreadsheet.
The first challenge was frozen columns. In Google Sheets, you can freeze the first column (or first few columns) so they remain visible while you scroll horizontally. This is essential in a CRM grid because the contact name is always in the first column, and without it visible, you lose context for every other field you are looking at.
Our implementation uses two synchronized scroll containers. The frozen section is a separate div with position: sticky; left: 0 and a z-index above the scrollable area. The scrollable section handles horizontal overflow independently. Both containers share the same vertical scroll position through a requestAnimationFrame loop that syncs their scrollTop values. We tried CSS-only approaches with position: sticky on individual cells, but the performance collapsed beyond 500 rows because the browser was recalculating sticky positions for every cell on every scroll frame.
When the scrollable area scrolls horizontally, we add a subtle box shadow to the right edge of the frozen column container. This gives users a visual cue that there is content scrolled out of view to the left. The shadow appears and disappears based on the scrollLeft value of the scrollable container. It is a small detail, but without it, users sometimes do not realize they have scrolled and get confused about which columns they are looking at.
In Google Sheets, there is no save button. You type a value and it is saved. Period. We replicated this with a save-on-blur pattern. When a user clicks a cell, it becomes an editable input. When they click away (blur) or press Tab or Enter, the value is persisted to the database via an API call. There is no save button, no confirmation dialog, no "unsaved changes" warning.
This sounds simple until you consider the edge cases:
CRM users frequently need to copy data between cells or paste data from external spreadsheets. We support three copy-paste patterns:
Click a cell, Ctrl+C copies its value to the clipboard as plain text. Click another cell, Ctrl+V pastes. This works across columns with type coercion -- pasting a string into a number field attempts to parse it, pasting a date string into a date field runs it through our date parser.
Click a cell and Shift+click another cell to select a rectangular range, just like Google Sheets. The selected range is highlighted with a blue border. Ctrl+C copies the entire range as tab-separated values. You can paste this range into another position in the grid, or into Google Sheets, Excel, or any other spreadsheet application. When pasting a multi-cell range back into the grid, we match the shape of the copied range to the target position and update all affected cells in a single batch API call.
Copy a block of cells from Google Sheets or Excel and paste it into our grid. We parse the clipboard content as TSV (tab-separated values), map columns by position, validate each value against the target column type, and apply all valid updates. Invalid cells are skipped with visual feedback showing which cells failed. This feature alone has saved teams hours of manual data entry when migrating from spreadsheets to SalesSheet.
Right-clicking a cell in our grid opens a context menu with actions relevant to that cell and row. The menu includes:
The context menu is positioned using a viewport-aware algorithm that flips the menu above or to the left of the cursor when there is not enough space below or to the right. On mobile, a long-press triggers a bottom sheet with the same options instead of a floating context menu.
The hardest part of building a spreadsheet-like grid is not the UX. It is the performance. A real CRM dataset might have 200,000 contacts with 30 custom fields each. That is 6 million cells. Rendering all of them in the DOM is impossible -- the browser would use gigabytes of memory and scrolling would drop to single-digit frames per second.
We use row and column virtualization. At any given moment, only the visible rows plus a small overscan buffer are rendered in the DOM. As the user scrolls, rows that leave the viewport are recycled and reused for rows entering the viewport. The scroll container's total height is set to match the full dataset height (row count times row height), so the scrollbar behaves correctly even though most rows do not exist in the DOM.
Column virtualization works the same way for horizontal scrolling. With 30+ custom fields, the horizontal extent can be significant. Only visible columns are rendered, with the frozen columns always present.
We debated pagination versus infinite scroll for months. Pagination breaks the spreadsheet mental model -- you cannot scroll through your data continuously. Infinite scroll is technically harder but preserves the "it is all right here" feeling. We went with infinite scroll backed by cursor-based pagination from the API. When the user scrolls near the bottom of the loaded dataset, we fetch the next page of records. The virtualization layer handles the growing dataset seamlessly. Users perceive a single continuous grid regardless of the dataset size.
Performance is not a feature. It is the absence of frustration. When a grid with 200,000 records scrolls at 60fps, users do not think "wow, this is fast." They think nothing at all. And that is the point.
Sorting and filtering 200,000 records on the main thread causes a visible UI freeze. We moved all sort and filter operations to a Web Worker. The worker receives the full dataset, applies the sort or filter criteria, and returns the resulting row indices. The main thread then updates the virtualization layer with the new order. For most operations, the worker completes in under 100 milliseconds, so the UI feels instant. For complex multi-column sorts on very large datasets, we show a subtle loading indicator in the column header.
Users can drag column borders to resize and drag column headers to reorder. Column widths are persisted per-user in the database, so each rep sees their preferred layout every time they load the grid. Reordering is also persisted. We use pointer events instead of mouse events for resize handles so the interaction works on both desktop and touch devices. During a resize drag, we throttle the reflow to every animation frame to prevent jank.
Full keyboard navigation is non-negotiable for a spreadsheet-like experience. Our grid supports:
All of these work in combination with the virtualization layer. When you press Page Down and the target row is not in the DOM, we scroll the container first, wait for the virtualization to render the target row, then set focus. This creates a seamless experience where the user never knows that most of the grid does not actually exist.
Building a CRM grid that feels like Google Sheets took us four months of focused engineering. The individual features are not revolutionary -- frozen columns, copy-paste, context menus -- but the integration of all of them into a cohesive experience that works at scale is where the real challenge lies. Three lessons stood out:
The result is a CRM grid that reps actually want to use, because it feels like the tool they already know. That is the highest compliment a CRM can receive.