Skip to content

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.

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.

Theme: agentic-ai-2025
<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.

Theme: agentic-ai-2025
<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.

Theme: agentic-ai-2025
<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.

Neither the hover reveal nor the per-destination icon is something Link can do today: it ships a single unicode indicator, always visible, with no prop for choosing the glyph. The three examples above wrap Link in page-local styling to show the intended behavior. The tables further down use the component as it currently is, so their indicators stay visible at rest.

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.

Theme: agentic-ai-2025
<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.

Pages with issues
Actions
3
18 Jul
Opens in new tab:
Merck KGaA

siteimprove.com/customers/merck-kgaa
3
16 Jul
3
8 Jul
Theme: agentic-ai-2025
<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")} />

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.

Theme: agentic-ai-2025
<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")} />

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.

Active policies
Actions
BJA test
Created 29/05/2026
20
Broken media files
Created 01/09/2025
59
Theme: agentic-ai-2025
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&apos;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

Do
  • 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: IconEdit for an editable view, IconArrowRight for a read-only drill-down.
  • Use IconOpenNew, not IconArrowRight, 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 ActionMenu only when there are more than three.
Don't
  • 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.