paddle.engineering [Design]

Reimagining data tables in our new design system

Tables are the foundation of data-rich applications. Rebuilding them from scratch for unknown data shapes, complex interactions, full accessibility, and cross-platform support turned out to be a story about how we think about building systems.

Tables are everywhere in our dashboard: transactions, customers, subscriptions, reports. They’re how our customers understand their revenue, track their subscribers, and manage their integrations. As a frontend engineer, they’re also deceptively complex to build.

At Paddle, we’re working on an entirely new design system. It includes new components, colors, and typography, but it’s more than that. It’s an opinionated philosophy about how things should look and feel, and how components should work together to create a unified experience for our customers.

All of the new screens in our dashboard are built with our new design system, like our pages for setting up payment recovery and cancellation flows, and we’re working through the rest of the dashboard to bring it all together.

The challenge wasn’t just making tables look good. It was designing for unknown data shapes, accessibility, configuration, and native-feeling platform experiences. We’re not building for a specific table, we’re building for every table we might ever want to build, both now and in the future. Our existing tables couldn’t do this well. This is the story of how we rebuilt them.

Introducing our new tables

This is how our data tables previously looked:

The Paddle dashboard's Transactions page in the previous design system, featuring a dark sidebar with full navigation and a table listing transactions with columns for customer email, amount, payment method, date paid, product, and status.
This is how our transaction page looked before we started rolling out our new design system.

And this is how we’ve reimagined them:

The Paddle dashboard's Transactions page in the new design system, with numbered callouts marking seven table features: the header row (1), column resizer (2), a data row (3), the scroll fade (4), the action menu (5), the full-width hover effect (6), and sub-components like tooltips and status pills (7).
This is the same transactions screen, built in our new design system.

Our new tables include:

  1. Header row
  2. Column resizer
  3. Data row
  4. Fade to indicate that content can be scrolled
  5. Action menu
  6. Hover effect that spans the full width of the page
  7. Subcomponents like tooltips, pills, and buttons

The visual difference might look subtle at first. Tables are fairly basic from a design perspective, they’re ultimately just grids of rows and columns, and our existing ones didn’t look bad.

But looks weren’t the real problem. Our existing tables weren’t fully accessible, they struggled when data shapes were unpredictable, and they gave engineers a fixed set of configuration options rather than real composability. They also never quite felt like Paddle. They were always a bit flat, and not quite at home with our design language.

This reflects a broader theme that we’re thinking about as part of our new design system work: how do we build something that’s functional, but still distinctly Paddle? If you saw a screenshot of part of our UI, would you be able to tell that it’s Paddle?

What we need tables to do

Let’s pause for a moment. Good design starts with functional requirements. Before we can think about how tables should look and feel, we need to dig into what they need to do.

1. Present all kinds of data

We’ve all seen a table before, right? Fundamentally, they’re fairly simple. They’re a way of representing structured data. Typically, each row represents a record and each column represents a field within that record.

In this example of an invoice list, we’re designing for data that we know. Each record contains a number, dates, and an amount.

NumberIssue dateDue dateTotal
10012026-09-012026-10-01$100.00
10022026-09-102026-10-10$200.00
10032026-10-142026-11-14$300.00
10042026-10-282026-11-28$400.00

Data tables, the kind we use in data-rich applications like Paddle, aren’t always like this. We don’t know the size and shape of the data that they contain. We might have a rough idea but, for the most part, we’re working in the gray. This is especially true for strings that can be very long but very important, like descriptions or API keys.

An API keys table showing five entries with short names like "HubSpot sync" alongside longer ones like "Paddle MCP server", with columns for key, status (Active, Revoked, Expiring soon), last used, expiry date and time, permissions, date created, and created by.
API key names vary in length, with some customers preferring very descriptive names. Expiry date and time is critical, and truncating the date isn't an option.

The real challenge for me and the team working on the design system is that we’re not designing for a specific table or set of known data, we’re building for every table that we might like to build — both now and in the future.

Engineers building tables need to be able to compose exactly what they need without being constrained by rigid configuration options.

2. Integrate with complex UIs, simply

Data tables don’t just present data, they let users work with that data.

A great example of this is the action menu, usually placed in the last column of a table. Action menus are a common pattern in data tables, and a widely understood way to give users quick access to actions for a record.

For example, in our API keys table, users can edit a key, revoke it, or copy its Paddle ID. All of these actions involve other components, like buttons, menus, drawers, modals, and tooltips.

An open dropdown action menu triggered by a three-dot button, showing three options: Edit API key, Copy ID, and Revoke — the last in red to signal a destructive action.
API key action menu with options to edit, copy ID, and revoke.

This presents us with a few interesting challenges.

From a usability perspective, taking an action on a record is one of the most important jobs that a user wants to do, so the action menu needs to be easily accessible — even in tables with a lot of columns.

On the implementation side, this creates layering complexity that goes beyond basic styling. When dropdowns, tooltips, and other components appear, they need to work reliably without z-index escalation wars.

3. Work well for everyone

Accessibility is one of the most important priorities for our new design system. About 15% of the global population has additional accessibility needs. Building inaccessible apps effectively excludes these users from participating in digital life.

The challenge when building tables is that there are a lot of different ways to navigate through a table. Tables support two different navigation models that work together simultaneously:

  • Tab navigation for moving through interactive elements like links and buttons. This is called document order navigation, and works with screen readers in “browse mode.”
  • Arrow key navigation for moving between cells. This is called spatial navigation, and works with screen readers in “focus mode.”

In total, there are over twenty keyboard shortcuts for tables. You also need to manage focus and state, cell relations, and dynamic content updates.

Our tables need to work with both of these navigation models and include full support for keyboard, screen readers, and other accessibility tools.

4. Feel at home on every platform

One of the big wins of adopting accessibility as a core principle is that features for accessibility often improve usability for everyone. If you think about the real world, curb cuts help wheelchair users cross the street safely. But, they also make crossing easier for people with strollers, young children, and elderly folks, too.

By nailing accessibility, we’re getting keyboard navigation for our power users for free. We want to take this one step further and make Paddle feel great no matter what device you’re on.

Our users access Paddle on everything from desktop workstations to iPads to mobile devices. Each platform has different interaction patterns and expectations:

Four illustrations showing the supported navigation input types, from left to right: a Mac trackpad with a two-finger swipe gesture, a mouse with a scroll wheel, a tablet showing a vertical flick gesture, and a set of keyboard arrow keys.
(L-R) Mac trackpad, mouse wheel, touch devices, keyboard navigation. Illustrations adapted from Apple.com.

  • Mac trackpad: Two-finger swipes feel natural, including the rubber band effect if you scroll too far.
  • Mouse wheel: Horizontal scroll wheels work as expected.
  • Touch devices: Flick gestures have proper momentum.
  • Keyboard navigation: Using arrow keys and other buttons just works, with no need to reimplement.

Our tables should be platform-native experiences, meaning they respect the differences between platforms rather than trying to create a one-size-fits-all interaction model.

React Aria as our foundation

Our functional requirements led us to React Aria as the foundation for our design system.

If you’ve not checked it out in a while, React Aria has evolved a lot since it first launched. Originally a set of headless, low-level hooks that required React Stately, it’s now a complete collection of high-level, unstyled components.

React Aria comes with components for common UI patterns, with rich, platform-native interactions and a strong focus on accessibility out-of-the-box. This covers all of our functional requirements.

Each component is broken down into individual parts with built-in states, render props, and slots, which means we can just build and design on top of them.

How it works

This is how the table we showed earlier is constructed:

transactions-table.tsx
<TablePendingOverlay isPending={isPending}>
<TableScrollArea stickyColumn="end">
<ResizableTableContainer>
<Table
variant="listing"
aria-label="Transactions"
className="transactions-table">
<TableHeader>
<TableColumn isRowHeader>
<ColumnContainer>
<ColumnLabel>Product(s)</ColumnLabel>
<ColumnResizer />
</ColumnContainer>
</TableColumn>
<TableColumn>
<ColumnContainer>
<ColumnLabel>Customer email</ColumnLabel>
<ColumnResizer />
</ColumnContainer>
</TableColumn>
<TableColumn>
<ColumnContainer>
<ColumnLabel>Payment amount</ColumnLabel>
</ColumnContainer>
</TableColumn>
<TableColumn>
<ColumnContainer>
<ColumnLabel>Payment method</ColumnLabel>
</ColumnContainer>
</TableColumn>
<TableColumn>
<ColumnContainer>
<ColumnLabel>Date paid</ColumnLabel>
</ColumnContainer>
</TableColumn>
<TableColumn>
<ColumnContainer>
<ColumnLabel>Status</ColumnLabel>
</ColumnContainer>
</TableColumn>
<TableColumn className="menu-column"></TableColumn>
</TableHeader>
<TableBody
items={transactions}
dependencies={[permissions.transactions.view]}
renderEmptyState={renderEmptyState}>
{(transaction) => (
<TableRow>
<TableCell ellipsis={true}>
<TransactionsTableProductCell
transactionLineItems={transaction.details.lineItems}
/>
</TableCell>
<TableCell ellipsis={true}>
{transaction.customer?.email}
</TableCell>
<TableCell className="transactions-table-amount-cell">
<TransactionTableCellAmount transaction={transaction} />
</TableCell>
<TableCell>
<TransactionTablePaymentMethodCell
payment={transaction.payments}
/>
</TableCell>
<TableCell>
<TransactionStatus />
</TableCell>
<TableCell className="transactions-table-status-cell">
<TransactionStatusTags transaction={transaction} />
</TableCell>
<TableCell className="menu-cell">
<TransactionsTableCellMenu transaction={transaction} />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ResizableTableContainer>
</TableScrollArea>
</TablePendingOverlay>

Composition over configuration

You can see we have separate <TableHeader> and <TableBody> components, each with their own <TableColumn> and <TableRow> components.

This is a break from how we designed components in the past, where we built monolithic components with lots of props like columns or dataSource.

On first glance, this means that tables require more boilerplate code. But, it means any engineer working with our components can build exactly what they need. API keys tables look different from transaction tables, and that’s fine. You can grab just the pieces you need and arrange them in the way that makes sense for your use case.

This reflects a broader trend in the industry, with libraries like Radix UI, and its more popular sibling shadcn/ui, adopting the same compositional approach.

Accessibility out-of-the-box

Accessibility is a solved problem with React Aria, and one of the core reasons we picked it as our foundation library. Beautifully accessible tables come as standard.

The reality is that users today expect enterprise-grade accessibility (and rightly so), regardless of whether you’re an enterprise or not. Adobe absolutely are an enterprise, and they have industry leaders who have solved accessibility problems we haven’t even discovered yet.

Going with React Aria means we can focus on the design and functionality of our tables, and leave the accessibility to the experts.

Horizontal scroll and visual polish

With React Aria taking care of the core functionality, we could focus on the design touches that really make tables shine.

Let’s start with responsive tables. Ever tried to open a spreadsheet on your phone? It’s not a great experience. Given their nature, tables aren’t generally suited for small screens.

There are a bunch of ways you can try to design around this, like hiding columns or collapsing rows into blocks like data cards. But, these approaches often make for a poor user experience, and they’re hard to build in a way that works with all data shapes.

My two cents: horizontal scroll is the best solution in most cases, especially when you’re designing for business software like Paddle where data shows most of our users access the dashboard on desktop or iPad.

Not only is it a pragmatic solution that’s easy to implement, horizontal scroll is a well-understood interaction pattern and works across platforms natively.

But, there’s a few interesting things going on with our horizontal scroll to improve usability.

Fade effect: there’s more to see here

One of the complications with horizontal scroll is that it’s not always immediately obvious that there’s more content in the table. This is especially true for folks using macOS, which hides scrollbars when using a touchpad until you start scrolling in an area.

To help here, we added a neat fade to the end of a row to show that there’s more content. This is a sticky element that appears to the right, hinting to users that there’s more content to scroll through.

Implementing the fade is very simple generally using a trick of absolutely positioned pseudo elements and a gradient. The real challenge is that our table rows change background color on hover. They switch instantly as you hover but then fade out when you leave.

Traditionally there’s no way to animate a gradient because the browser can’t interpolate between colors in the gradient. However, with the new css @property you can define a value in a type that the browser already knows how to interpolate and transition between them. So you can do something like:

fade.css
@property --fade-color {
syntax: "<color>";
inherits: true;
initial-value: #0000;
}
.row {
background: white;
--fade-color: white;
transition: background-color 150ms ease;
}
.row:hover {
background: gray;
--fade-color: gray;
}
.fade-element {
background: linear-gradient(90deg, var(--fade-color), transparent);
transition: --fade-color 150ms ease;
}

And with that you get a linear gradient that smoothly transitions between colors, with the same timing and duration as the row background color.

Extended backgrounds: making tables feel “Paddle”

To lift tables a little, we added a nice bit of visual flair with table rows that extend the full width of their container.

When you hover over a table row, the background color doesn’t just fill the row cells — it extends beyond the table boundaries, creating a full-width highlight that spans the entire container.

This animation shows the extended background in action. The table container is toggled in blue to illustrate how the header and hover row extend beyond the table itself:

This is one of those subtle design touches that makes tables feel more “Paddle.” The entire row gets highlighted, edge to edge, as if the row owns that horizontal space and the data really belongs.

Implementing this was surprisingly complicated, and we had to solve a bunch of tricky problems:

  • Tables are constrained to their content width. To make backgrounds extend beyond this, we need the background to “break out” of the table’s layout while keeping the content properly centered.
  • The extended backgrounds need to behave as expected. They have to sit behind the table content and not interfere with dropdown menus, tooltips, or other overlays.
  • When the table is horizontally scrollable, the extended backgrounds need to stay properly positioned and not interfere with the scroll behavior.

We initially implemented a JavaScript approach here. It worked using some very clever math that detected scroll events, calculated positions and sizes, then animated an element in the background.

While this worked, we ultimately decided to go for a pure CSS approach. We used a nifty little trick instead:

example.css
td,
th {
position: relative;
}
td::before,
th::before {
content: "";
position: absolute;
inset: 0;
background-color: var(--row-background, transparent);
box-shadow: 0 0 0 6vmax var(--row-background, transparent);
clip-path: inset(0 -100vw);
pointer-events: none;
transition:
background-color 150ms ease,
box-shadow 150ms ease;
}
tr:hover td::before,
tr:hover th::before {
--row-background: var(--paddle-color-background-hover);
}

The reason we chose CSS over JavaScript is more philosophical than anything, and comes back to our functional requirements: where possible, we want to build platform-native experiences with our design system.

Pure CSS means we don’t need to reengineer things that we get for free. The browser knows about the user’s operating system and device, their accessibility preferences, their input device, and exactly how scrolling should feel.

There’s also performance to consider: JavaScript runs on your CPU in a single thread, but CSS is kicked to your GPU. This becomes especially important when you’re dealing with long, data-heavy tables.

Managing complex interactions

Responsive design solved one layer of complexity, but tables also need to handle sophisticated UI interactions without breaking. For example, we touched on our action menu earlier in the post. It’s a button in the last column of a table row that lets users take actions on a record.

Now if you’ve ever worked with menus in a table like this, you’re probably triggered thinking about z-index. Every frontend dev has a z-index troubleshooting story (z-index: 9999999;, anyone?). It’s a powerful tool, but it’s also difficult to manage and even more difficult to debug.

We used z-index liberally in our previous tables, and we initially used it in our new tables, too. A great example of this is the background hover effect we just talked about: the background hover effect had a z-index value and the table data had a higher z-index value so that it’d appear on top of the row.

Sounds fine, right? This is exactly what z-index is designed for. However, when we added an action menu button, the menu appeared behind the hover background and table content. This meant we were reaching for a z-index again, finding ourselves in an escalation war.

Given we’re designing for the design system, so not designing for a specific table, this didn’t work for us. Instead, we rearchitected our tables to avoid using z-index where we can. We opted for React Portals, which push new content to the end of the DOM and mean it renders on top of any preceding HTML that isn’t already in its own stacking context.

This means that when a dropdown menu or other component appears, it relies on the normal HTML stacking context. Elements are stacked in the order that they were rendered and their position in the DOM, meaning the menu will always appear above the table row.

Loading states

A side effect of this change is that when we do use a z-index, we can be sure it’s going to work.

One place we do explicitly use z-index is for pending states. When a user is performing an action, we need to show a loading indicator to let them know that something is happening.

A subscriptions data table with rows showing customer emails, products, billing periods, and status badges, overlaid by a circular spinner loading indicator centered in the visible table area.
Tables show a loading indicator when a user is performing an action.

Implementing a pending state isn’t too tricky in itself, but it’s a place where thoughtful design is important. Data tables can be very tall, containing up to 100 records, but pending states have to be visible anywhere in that table — where should loading indicator appear?

For example, you can change filters at the top of a table and go to the next page of the table at the bottom. When you do this, you need to be able to see the loading indicator. If you’re at the bottom and it appears at the very top, or even in the middle, you’re not going to know what’s going on.

To implement pending states, we use an absolutely-positioned overlay that appears on top of the table when a loading or pending state is active. We decided to position it at the top of whatever portion of the table is currently visible to the user.

A mostly empty Transactions table with a spinner centered in the currently visible area, with dashed arrows pointing outward in four directions to illustrate that the spinner is anchored to the visible viewport rather than the full table height and width.
Spinners are positioned in the part of the table that's currently visible to the user.

Doing this ourselves is mathematically complex — we’d need to calculate the intersection of the table’s scroll position with the user’s viewport, then position the spinner accordingly.

We solved this by using CSS position: sticky, which lets the browser handle the positioning calculations for us. It means that the spinner always stays visible within the table’s scroll area, appearing at the top of whatever the user can see.

example.css
/* 1. Outer wrapper — creates the positioning context */
.pending-overlay-wrapper {
position: relative;
}
/* 2. Overlay — stretches over the full table */
.pending-overlay {
position: absolute;
inset: 0;
z-index: 1;
backdrop-filter: blur(2px);
background: rgba(255, 255, 255, 0.6);
padding-top: 120px; /* push spinner down from top edge */
display: grid;
justify-items: center; /* horizontally center the spinner */
}
/* 3. Spinner — sticks within the visible viewport */
.pending-spinner {
position: sticky;
top: 80px; /* 80px from top of visible area */
bottom: 80px; /* 80px from bottom of visible area */
width: 4rem;
height: 4rem;
}

Why a spinner?

While there are a bunch of ways to convey pending states, we went with a spinner because it’s a well-understood pattern that works across platforms and devices.

We got quite far with a skeleton loader concept, which showed the table structure while data loads, but this brings us back to our core challenge: we don’t know the shape of the data. A skeleton for API keys looks different from a skeleton for transactions. Building skeletons for every possible table configuration would be more complex than the problem we’re solving.

In the end we went with a spinner. Sometimes “good enough” is the right choice. Our current approach works reliably across all table types, and users understand the loading state immediately. We can revisit later if we need to.

Pagination

Our core challenge of building for unknown data shapes also applies to pagination, too. Some tables have 50 rows, others have 50,000. Some tables don’t need pagination at all.

Rather than build pagination directly into the table component with props like pageSize or showSizeSelector, we made it a separate component that works with any data source. While there’s more setup up front, it gives us a lot more flexibility.

A pagination control bar with an "Items per page" dropdown set to 25 on the left, and Previous and Next navigation links with chevron icons on the right.
Our pagination component includes a page size selector and a pager with previous and next links.

This component writes parameters into the URL, and anything that needs to paginate can listen and rerender itself.

PaginationExample.tsx
<Pagination>
<PageSizeSelect />
<Pager>
<PagerPreviousLink />
<PagerNextLink />
</Pager>
</Pagination>

The added benefit here is that URLs are shareable. Team members can load the exact view you’re looking at. It’s a small UX win that costs nothing.

Why cursor-based pagination?

Under the hood, we use the same API that we offer to customers to show data in our dashboard, and the Paddle API uses cursor-based pagination.

This is a core part of our engineering culture at Paddle: we’re all consumers of the Paddle API. This isn’t just about technical consistency, it’s about validating our API design. If we discover pain points or missing data when working with our API as part of the dashboard, we know our customers might run into these issues, too.

One downside of cursor-based pagination is that it can make sorting tricky. For our initial version, we’ve focused on search and filtering. Data shows these are the two most common customer requirements for working with data.

Final thoughts

We started our design system journey with tables because they’re a component that we use a lot, and we wanted to get it right. They’re a fundamental part of our user experience.

They also turned out to be the ideal first test of what our design system is actually trying to be. To get tables right, we had to be clear on what “right” means for us:

  • Accessible by default.
  • Platform-native where we can be.
  • Composable rather than configured.
  • Pragmatic when we need to be, because sometimes a spinner and a horizontal scroll really are the right answer.

These are principles that we’re trying to carry through everything else we build in our dashboard. Every screen we ship using these tables is a little more consistent, a little more accessible, and a little faster to create than it would have been before. That compounds across the whole dashboard.

Tables are always going to be a rite of passage for frontend developers. Browser engines lay out tables differently than everything else; they take any shape of data, meaning something in a cell 500 rows down can impact the whole layout; and they’re verbose by nature.

But with the right foundation and thoughtful tradeoffs, you can build tables that don’t suck. They might even be delightful.

// About the author
Chris Guy profile photo

About Chris Guy

Christien Guy is a Senior Frontend Engineer at Paddle, working on the design system and components that power our user-facing experiences. He's drawn to the territory where design and engineering overlap, where how something feels is just as important as how it's built.

[We're hiring]

Build the stack that simplifies their stack

Join us and help solve the biggest product and engineering challenges in fintech. Make a difference to thousands of SaaS, app, and AI companies on their journey to global growth.

Find your role