Row links
When a table row contains a navigation link, that link belongs in the first column, not the last. The last column is for secondary actions. Whether a row needs a link at all depends on whether it has a page of its own to open.
This guideline applies to any table whose rows represent an object, and it uses the Link component. For the anatomy and column order of the table itself, see Data table.
Composition
Four rules. Together they make a row's behaviour predictable before anyone clicks it.
1. A row's navigation link goes in the first column
If a row has its own destination, that link lives in the first column, identifying the row and navigating into its detail. Apply it to every row, every table of this kind, the same way. Never a per-table judgment call, never replaced by a last-column button. If the row is purely presentational data with no drill-down target, the first column does not need to be a link at all.
2. Same tab or new tab is decided by the destination, not the table
Same tab when the destination is another object one level deeper in the same product. New tab when the destination is a large or external surface the user wants open alongside this table, such as a full report, a live site, or a CMS. Either way the indicator is revealed on hover, and the icon is what tells the two apart.
3. Related detail opens in place, on its own control
To show related detail without losing the row, keep it on the page. A count of related records, such as the pages an issue affects, expands into a nested table beneath the row, using the same expandable cell as the Data table pattern. A summary of the row itself opens in a side panel, from its own visible control such as the “View details” button in the Active policies example below. Neither control navigates, and neither shares a click with the link in the first column.
4. Secondary row actions are icon buttons, with an action menu as overflow
Show secondary row actions as icon-only buttons in the last column, so each one is a single click away. Move them into an ActionMenu, opened with IconOptions, only when a row has more than three, where a strip of icons would start to crowd the row. Row actions are secondary, never the only way to reach the row's own detail. If no row in the table has secondary actions, leave the actions column out altogether. When there is one, it needs no visible header: the buttons already read as actions, and a label only adds noise. Give it a screen-reader-only label instead, such as <SrOnly>Actions</SrOnly>, so the column still has a name.
Link treatment
The name cell is not a classic underlined hyperlink at rest. Hover the examples below to see the difference. The icon the hover reveals should say what clicking will do, not merely that the name is clickable.
Same tab, read-only detail
Plain text at rest, no underline and no icon. Hover or keyboard focus reveals the underline plus a trailing IconArrowRight. A list of fifty rows does not need fifty arrows saying the same thing.
<RowLink href="#link-treatment" icon={<IconArrowRight />}>
Broken links on level 2 pages
</RowLink>Same tab, editable view
When the destination is a view the user can edit, such as a site's settings or a policy's definition, reveal IconEdit instead. The row then says what it hands you, not just that it goes somewhere. Two tables that look alike but land on an edit view and a report should not carry the same icon.
<RowLink href="#link-treatment" icon={<IconEdit />}>
Academy
</RowLink>New tab
Same reveal, different icon. Hover or keyboard focus shows IconOpenNew instead of IconArrowRight, so the row says where it is going and that it will open somewhere else, without either mark sitting there at rest.
<RowLink href="https://siteimprove.com" icon={<IconOpenNew />} openNew>
Siteimprove Careers
</RowLink>All three indicators are real Fancy icons, not the literal unicode characters Fancy's own Link ships, and all are revealed on hover. Pick the one that matches the destination: IconEdit for an editable view, IconArrowRight for a read-only drill-down, IconOpenNew for a surface that opens in a new tab. A count is not a link either. Where it opens related records, it expands them in place with the table's own expand control, not a link icon.
Layout type
Four real tables, each showing one of the cases. Everything here is live, and every one shows the pattern as it should be built, not as it is today.
Issues: same-tab drill-down, with affected pages expanding in place
Hover the issue title to reveal its arrow, then click it to go one level deeper. The Pages count is the row's second control: it expands the affected pages into a nested table beneath the row, so they can be checked without leaving the list. Each affected page has a destination of its own, so it carries its own row link.
Accessibility | 0.52 | ||
SEO | 1.63 | ||
Quality Assurance | 3.50 | ||
Quality Assurance | 7.00 |
<Table
caption="Issues"
rowKey={(dto) => dto.id}
columns={[
{
header: { content: "Issue", property: "title" },
options: { isKeyColumn: true },
render: (dto) => (
<RowLink href="#layout-type" icon={<IconArrowRight />}>
{dto.title}
</RowLink>
),
},
{
header: { content: "Pages", property: "pages" },
options: { align: "right" },
render: (dto) => dto.pages,
expandOptions: {
canCellExpand: (dto) => dto.pages > 0,
cellExpandRenderer: (dto) => <AffectedPagesTable issue={dto} />,
},
},
{ header: { content: "Module", property: "module" }, render: (dto) => dto.module },
{
header: { content: "Points", property: "points" },
options: { align: "right" },
render: (dto) => (
<FormattedNumber number={dto.points} format="number" digits={2} alwaysShowDigits />
),
},
]}
{...useSorted(issues, "points")}
/>Pages: new tab, with secondary actions as icon buttons
Hover a title to reveal IconOpenNew, which marks a destination that opens in a new tab. The last column holds the three secondary actions as icon buttons. A fourth would tip them into an ActionMenu. None of them is the way into the row itself.
Actions | |||
|---|---|---|---|
Opens in new tab: Siteimprove Careersjobs.jobvite.com/siteimprove | 3 | 18 Jul | |
Opens in new tab: Merck KGaAsiteimprove.com/customers/merck-kgaa | 3 | 16 Jul | |
Opens in new tab: Siteimprove Marketing Performancesiteimprove.com/marketing-performance | 3 | 8 Jul |
<Table
caption="Pages with issues"
rowKey={(dto) => dto.id}
columns={[
{
header: { content: "Title / URL", property: "title" },
options: { isKeyColumn: true },
render: (dto) => (
<span>
<RowLink href={`https://${dto.url}`} icon={<IconOpenNew />} openNew>
{dto.title}
</RowLink>
<br />
<InlineText size="small" tone="subtle">
{dto.url}
</InlineText>
</span>
),
},
{
header: { content: "Issues", property: "issues" },
options: { align: "right" },
render: (dto) => dto.issues,
},
{ header: { content: "Last checked", property: "checked" }, render: (dto) => dto.checked },
{
header: { contentNode: <SrOnly>Actions</SrOnly> },
options: { align: "right" },
render: (dto) =>
rowActions([
{ label: `View live page for ${dto.title}`, icon: <IconOpenNew /> },
{ label: `View ${dto.title} in CMS`, icon: <IconCms /> },
{ label: `Run page check on ${dto.title}`, icon: <IconScan /> },
]),
},
]}
{...useSorted(pageRows, "issues")}
/>Page groups: the name carries the link
Rename and Duplicate are secondary, so they sit in the last column as icon buttons, well under the overflow threshold. The first column is the way into the row.
Actions | |||
|---|---|---|---|
3 | 2 | ||
5 | 8 | ||
12 | 34 |
<Table
caption="Page groups"
rowKey={(dto) => dto.id}
columns={[
{
header: { content: "Page group", property: "name" },
options: { isKeyColumn: true },
render: (dto) => (
<RowLink href="#layout-type" icon={<IconArrowRight />}>
{dto.name}
</RowLink>
),
},
{
header: { content: "Sites", property: "sites" },
options: { align: "right" },
render: (dto) => dto.sites,
},
{
header: { content: "Issues", property: "issues" },
options: { align: "right" },
render: (dto) => dto.issues,
},
{
header: { contentNode: <SrOnly>Actions</SrOnly> },
options: { align: "right" },
render: (dto) =>
rowActions([
{ label: `Rename ${dto.name}`, icon: <IconEdit /> },
{ label: `Duplicate ${dto.name}`, icon: <IconCopy /> },
]),
},
]}
{...useSorted(pageGroups, "issues")}
/>Active policies: link to the page, button to the panel
The policy name links to the full page. “View details” is a separate control that opens a sneak peek. The panel's primary action carries the same label as the name link, because it goes to the same page.
Actions | ||
|---|---|---|
BJA test Created 29/05/2026 | 20 | |
Broken media files Created 01/09/2025 | 59 |
const [panelFor, setPanelFor] = useState<Policy | null>(null);
return (
<>
<Table
caption="Active policies"
rowKey={(dto) => dto.id}
columns={[
{
header: { content: "Policy", property: "name" },
options: { isKeyColumn: true },
render: (dto) => (
<span>
<RowLink href="#layout-type" icon={<IconArrowRight />}>
{dto.name}
</RowLink>
<br />
<InlineText size="small" tone="subtle">
Created {dto.created}
</InlineText>
</span>
),
},
{
header: { content: "Matches", property: "matches" },
options: { align: "right" },
render: (dto) => dto.matches,
},
{
header: { contentNode: <SrOnly>Actions</SrOnly> },
options: { align: "right" },
render: (dto) => <Button onClick={() => setPanelFor(dto)}>View details</Button>,
},
]}
{...useSorted(policies, "matches")}
/>
<SidePanel
shown={panelFor !== null}
headerTitle={panelFor?.name ?? ""}
onClose={() => setPanelFor(null)}
>
<Content>
<Paragraph>
A sneak peek at the policy. The primary action below carries the same label as the name
link in the row, because it goes to the same page, so the two controls never disagree
about where the row lives.
</Paragraph>
<Badge type="neutral">Same destination as the name link</Badge>
</Content>
<SidePanel.Footer>
<ActionBar
primary={{ children: panelFor?.name ?? "", href: "#layout-type" }}
cancel={{ children: "Close", onClick: () => setPanelFor(null) }}
/>
</SidePanel.Footer>
</SidePanel>
</>
);Guidelines
- When a row has a destination, make the first column its link. Every row, every table of that kind, the same way.
- Reveal the indicator on hover and keyboard focus, never at rest.
- Match the revealed icon to the destination:
IconEditfor an editable view,IconArrowRightfor a read-only drill-down. - Use
IconOpenNew, notIconArrowRight, when the destination opens in a new tab. - Expand a count of related records into a nested table, and open a summary of the row in a side panel, each from its own control.
- Show secondary row actions as icon-only buttons, and switch to
ActionMenuonly when there are more than three.
- Leave the first column as inert text with a last-column button as the only way into the row.
- Underline the name cell at rest, or show its indicator on every row at once. That is what makes a long table look busy.
- Use a unicode arrow where a real Fancy icon exists.
- Give every destination the same icon, so it says only that the name is clickable.
- Open a side panel or a nested table from the link in the first column. The first column navigates to the row's destination.
- Reach for an action menu when two or three icon buttons would do, since it hides the actions behind an extra click.
- Add an actions column to a table whose rows have no secondary actions.
- Force the first column into a link when the row has no page of its own, just to look consistent with tables that do.