Skip to content

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.

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.

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.

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.

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 icon.

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 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.

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 icon. Every example on this page wraps Link in a small page-local helper, RowLink, to show the intended behavior. RowLink is a stand-in for this page only, not a Fancy component.

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.

Issues
Accessibility
0.52
SEO
1.63
Quality Assurance
3.50
Quality Assurance
7.00
Theme: agentic-ai-2025
<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.

Pages with issues
Actions
Opens in new tab:
Siteimprove Careers

jobs.jobvite.com/siteimprove
3
18 Jul
Opens in new tab:
Merck KGaA

siteimprove.com/customers/merck-kgaa
3
16 Jul
Opens in new tab:
Siteimprove Marketing Performance

siteimprove.com/marketing-performance
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> <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")} />

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.

Page groups
Actions
3
2
5
8
12
34
Theme: agentic-ai-2025
<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")} />

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.

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> <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

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