Link
When a row has somewhere to go, that link belongs in column 1, not the last column. The last column is always secondary actions. What changes is only where the link goes, and whether one exists at all is decided by the data, not by habit.
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. Column 1 carries the link, when the row has somewhere to go
If a row has its own destination, that link lives in column 1, 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, column 1 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. A side panel is a second, separate control
For peeking at related detail without losing the row. It gets its own visible control, such as the “View details” button in the Active policies example below. It never shares a control with the column 1 link, and it is never routed through a count.
4. Secondary row actions are icon buttons, with the kebab as overflow
Show secondary row actions as icon-only buttons in the last column, so each one is a single click away. Reach for an ActionMenu (kebab, using 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. Where a row has no secondary actions, the last column stays empty.
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 glyph. 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 glyph.
<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 cell stays a plain number, since it is not a link and does not need the same treatment.
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, and nothing else to click
Hover the issue title to reveal its arrow, then click it to go one level deeper. The counts carry no action, so there is exactly one thing to click in a row.
1 | Accessibility | 0.52 | |
14 | SEO | 1.63 | |
12 | Quality Assurance | 3.50 | |
32 | Quality Assurance | 7.00 |
<Table
caption="Issues"
rowKey={(dto) => dto.id}
columns={[
{
header: { content: "Issue", property: "title" },
options: { isKeyColumn: true },
render: (dto) => <Link href="#layout-type">{dto.title}</Link>,
},
{
header: { content: "Pages", property: "pages" },
options: { align: "right" },
render: (dto) => dto.pages,
},
{ 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>
<Link href={`https://${dto.url}`} openNew>
{dto.title}
</Link>
<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. Column 1 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) => <Link href="#layout-type">{dto.name}</Link>,
},
{
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 split done right, and already live. The policy name links to the full page. “View details” is a separate control that opens a sneak peek, and that panel's own primary action goes to the same page the name link does.
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>
<Link href="#layout-type">{dto.name}</Link>
<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. This panel's own primary action goes to exactly the
same page the name link does, 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>
<Button variant="primary" onClick={() => setPanelFor(null)}>
View policy details
</Button>
</SidePanel.Footer>
</SidePanel>
</>
);Guidelines
- When a row has a destination, make column 1 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. - Give a side panel its own control, separate from the column 1 link.
- Show secondary row actions as icon-only buttons, and switch to
ActionMenuonly when there are more than three.
- Leave column 1 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 glyph, so the icon says only that the name is clickable.
- Route a peek through column 1. A count is not a link, so it keeps its plain number.
- Reach for a kebab when two or three icon buttons would do, since it hides the actions behind an extra click.
- Force column 1 into a link when the row has nowhere to go, just to look consistent with tables that do.