ui(frontend): Etherscan-style pill tabs on address and contract pages#125
Conversation
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.
There was a problem hiding this comment.
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.
| 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 { |
There was a problem hiding this comment.
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
- Shared utility functions and reusable UI components should be placed in common locations (e.g., app/components/) to avoid code duplication and improve maintainability.
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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.
| 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" |
There was a problem hiding this comment.
Done: dropped the new utility and reused the existing hide-scrollbar class.
| <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), | ||
| }))} | ||
| /> |
There was a problem hiding this comment.
With the generic TabPillBar component, we can pass the setTab handler directly to onSelect without needing the unsafe as TabKey type assertion.
| <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), | |
| }))} | |
| /> |
There was a problem hiding this comment.
Done: TabPillBar is now generic over the key union; both call sites pass their handlers directly with no assertions.
| <TabPillBar | ||
| ariaLabel="Token contract sections" | ||
| activeKey={activeTab} | ||
| onSelect={(key) => setActiveTab(key as typeof activeTab)} | ||
| tabs={tabs.map((tab) => ({ key: tab.id, label: tab.label }))} | ||
| /> |
There was a problem hiding this comment.
With the generic TabPillBar component, we can pass the setActiveTab handler directly to onSelect without needing the unsafe as typeof activeTab type assertion.
| <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 }))} | |
| /> |
There was a problem hiding this comment.
Done: TabPillBar is now generic over the key union; both call sites pass their handlers directly with no assertions.
| /* 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done: dropped the new utility and reused the existing hide-scrollbar class.
…r (review follow-ups)
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-autorow (necessary, the tabs don't fit a phone screen), but styled as underline tabs sliding over a staticborder-bit 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
TabPillBarcomponent used by both the wallet address tabs and the token contract tabs:#2d2d2d) otherwise,gap-2separation, count badges preserved.scrollbar-noneutility in globals.css.?tab=state, lazy panel mounting, ARIA tablist/tab/tabpanel semantics all unchanged. The contract page tab bar keeps its local state behavior.Verification
?tab=internal, badges render, screenshots reviewed on both form factors.npm run lint0 errors,npm test114/114,npm run buildgreen.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.