Skip to content

ui(frontend): Etherscan-style pill tabs on address and contract pages#125

Merged
moscowchill merged 2 commits into
devfrom
ui/pill-tab-bar
Jun 11, 2026
Merged

ui(frontend): Etherscan-style pill tabs on address and contract pages#125
moscowchill merged 2 commits into
devfrom
ui/pill-tab-bar

Conversation

@moscowchill

Copy link
Copy Markdown
Contributor

Problem

On phones, the address page tab bar (Transactions / Internal Txns / Token Transfers / Tokens / NFTs) can be grabbed and dragged: it is an overflow-x-auto row (necessary, the tabs don't fit a phone screen), but styled as underline tabs sliding over a static border-b it reads as a broken, unattached element. There is also little visual separation between tabs compared to e.g. Etherscan's pill buttons.

Change

New shared TabPillBar component used by both the wallet address tabs and the token contract tabs:

  • Each tab is a filled chip: accent orange with black text when active, dark gray (#2d2d2d) otherwise, gap-2 separation, count badges preserved.
  • The row still scrolls horizontally on narrow screens (it must), but a chip row visually communicates that, the same pattern Etherscan uses. Scrollbar hidden via a new scrollbar-none utility in globals.css.
  • No logic changes: URL-driven ?tab= state, lazy panel mounting, ARIA tablist/tab/tabpanel semantics all unchanged. The contract page tab bar keeps its local state behavior.

Verification

  • Playwright at 390x844 (iPhone) and 1280x900 (desktop) against a production build: pages stay exactly viewport-width, tab click updates ?tab=internal, badges render, screenshots reviewed on both form factors.
  • npm run lint 0 errors, npm test 114/114, npm run build green.

Risk

Pure presentational change; the two former inline tab button styles are replaced by one shared component. ARIA ids (tab-<key> / tabpanel-<key>) are emitted only for the address page (withPanelIds), matching the previous markup.

The old underline tab bar sat inside an overflow-x-auto container: on
phones the tabs slide along a static bottom border with no visual
affordance, which reads as a broken draggable element rather than an
intentional scrollable row.

New shared TabPillBar component: filled chip per tab (accent orange
when active, dark gray otherwise), gap-separated, horizontal scroll
with hidden scrollbar (new scrollbar-none utility), matching the
Etherscan pattern. Tab state logic (URL-driven ?tab=, lazy panel
mounting, ARIA tablist semantics) is unchanged; both the wallet
address tab bar and the token contract tab bar now render through the
shared component.

Verified at 390px mobile and 1280px desktop: pages stay viewport-width,
tab switching updates ?tab=, badges intact.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a reusable TabPillBar component to replace the old underline-style tabs on the address and token contract pages with Etherscan-style pill tabs, improving the mobile scrolling experience. Feedback focuses on making the TabPillBar component generic over the tab key type to eliminate unsafe type assertions, and reusing the existing hide-scrollbar utility class in globals.css instead of introducing a duplicate scrollbar-none utility.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +3 to +33
export interface TabPill {
key: string;
label: string;
/** Optional count rendered as "(badge)" after the label. */
badge?: string | null;
}

/**
* Etherscan-style pill tab bar, shared by the address and token contract
* pages. Replaces the old underline tabs: an underline indicator sliding
* over a static border-b inside an overflow-x-auto container read as
* "broken/draggable" on phones, while a row of filled chips makes the
* horizontal scroll look intentional. The scrollbar is hidden
* (scrollbar-none) like Etherscan's mobile tab row.
*
* When `withPanelIds` is set, buttons emit the tab-<key> / tabpanel-<key>
* id pairing the address page's tabpanels reference via aria-labelledby.
*/
export default function TabPillBar({
tabs,
activeKey,
onSelect,
ariaLabel,
withPanelIds = false,
}: {
tabs: TabPill[];
activeKey: string;
onSelect: (key: string) => void;
ariaLabel: string;
withPanelIds?: boolean;
}): JSX.Element {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We can make TabPillBar generic over the tab key type T extends string. This allows the compiler to enforce that the activeKey and the keys in the tabs array match exactly, and it eliminates the need for unsafe type assertions (like as TabKey or as typeof activeTab) in the parent components when handling onSelect.

export interface TabPill<T extends string = string> {
  key: T;
  label: string;
  /** Optional count rendered as "(badge)" after the label. */
  badge?: string | null;
}

/**
 * Etherscan-style pill tab bar, shared by the address and token contract
 * pages. Replaces the old underline tabs: an underline indicator sliding
 * over a static border-b inside an overflow-x-auto container read as
 * "broken/draggable" on phones, while a row of filled chips makes the
 * horizontal scroll look intentional. The scrollbar is hidden
 * (scrollbar-none) like Etherscan's mobile tab row.
 *
 * When withPanelIds is set, buttons emit the tab-key / tabpanel-key
 * id pairing the address page's tabpanels reference via aria-labelledby.
 */
export default function TabPillBar<T extends string>({
  tabs,
  activeKey,
  onSelect,
  ariaLabel,
  withPanelIds = false,
}: {
  tabs: TabPill<T>[];
  activeKey: T;
  onSelect: (key: T) => void;
  ariaLabel: string;
  withPanelIds?: boolean;
}): JSX.Element {
References
  1. Shared utility functions and reusable UI components should be placed in common locations (e.g., app/components/) to avoid code duplication and improve maintainability.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: TabPillBar is now generic over the key union; both call sites pass their handlers directly with no assertions.

<div
role="tablist"
aria-label={ariaLabel}
className="flex gap-2 overflow-x-auto scrollbar-none -mx-1 px-1 py-1"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the existing hide-scrollbar utility class instead of the duplicate scrollbar-none class. This avoids redundant CSS rules since hide-scrollbar is already defined at the bottom of globals.css.

Suggested change
className="flex gap-2 overflow-x-auto scrollbar-none -mx-1 px-1 py-1"
className="flex gap-2 overflow-x-auto hide-scrollbar -mx-1 px-1 py-1"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: dropped the new utility and reused the existing hide-scrollbar class.

Comment on lines +216 to +226
<TabPillBar
ariaLabel="Address activity sections"
withPanelIds
activeKey={activeTab}
onSelect={(key) => setTab(key as TabKey)}
tabs={TAB_KEYS.map((key) => ({
key,
label: TAB_LABEL[key],
badge: badge(key),
}))}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

With the generic TabPillBar component, we can pass the setTab handler directly to onSelect without needing the unsafe as TabKey type assertion.

Suggested change
<TabPillBar
ariaLabel="Address activity sections"
withPanelIds
activeKey={activeTab}
onSelect={(key) => setTab(key as TabKey)}
tabs={TAB_KEYS.map((key) => ({
key,
label: TAB_LABEL[key],
badge: badge(key),
}))}
/>
<TabPillBar
ariaLabel="Address activity sections"
withPanelIds
activeKey={activeTab}
onSelect={setTab}
tabs={TAB_KEYS.map((key) => ({
key,
label: TAB_LABEL[key],
badge: badge(key),
}))}
/>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: TabPillBar is now generic over the key union; both call sites pass their handlers directly with no assertions.

Comment on lines +452 to +457
<TabPillBar
ariaLabel="Token contract sections"
activeKey={activeTab}
onSelect={(key) => setActiveTab(key as typeof activeTab)}
tabs={tabs.map((tab) => ({ key: tab.id, label: tab.label }))}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

With the generic TabPillBar component, we can pass the setActiveTab handler directly to onSelect without needing the unsafe as typeof activeTab type assertion.

Suggested change
<TabPillBar
ariaLabel="Token contract sections"
activeKey={activeTab}
onSelect={(key) => setActiveTab(key as typeof activeTab)}
tabs={tabs.map((tab) => ({ key: tab.id, label: tab.label }))}
/>
<TabPillBar
ariaLabel="Token contract sections"
activeKey={activeTab}
onSelect={setActiveTab}
tabs={tabs.map((tab) => ({ key: tab.id, label: tab.label }))}
/>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: TabPillBar is now generic over the key union; both call sites pass their handlers directly with no assertions.

Comment thread ExplorerFrontend/app/globals.css Outdated
Comment on lines +207 to +215
/* Hide the scrollbar entirely (pill tab rows scroll horizontally on
mobile; the chips themselves are the affordance, like Etherscan). */
@utility scrollbar-none {
scrollbar-width: none;

&::-webkit-scrollbar {
display: none;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is already an existing .hide-scrollbar utility defined at the bottom of this file (lines 289-296) that achieves the same result (and also includes -ms-overflow-style: none for legacy IE/Edge support). To avoid redundant CSS definitions, we should reuse the existing .hide-scrollbar class instead of introducing a duplicate scrollbar-none utility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: dropped the new utility and reused the existing hide-scrollbar class.

@moscowchill moscowchill merged commit 96561fd into dev Jun 11, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant