2000 Online Marketplace — Item listings, auction bidding, countdown timers, seller profiles, search/filter, watchlist. eBay launched in 1995 as AuctionWeb and grew into the world's largest online marketplace. It pioneered the auction-based e-commerce model — fixed-price and auction hybrid — and taught the web how to handle real-time listings, bid tracking, and trust-based seller ratings. Inspired by eBay circa 2000 and the evolution of online marketplaces into today's peer-to-peer commerce platforms.
HTML Structure
CompleteWhy learn this?
Online marketplaces are the backbone of e-commerce — eBay, Amazon, Etsy, and Mercari all share the same fundamental HTML patterns: a branded header with search and navigation, a category sidebar or top bar, and a grid of product cards with key metadata. eBay's early 2000s design is particularly instructive because it blends auction listings (with time remaining, bid counts, and current prices) alongside fixed-price "Buy It Now" items — all in a single grid layout. Building an eBay clone teaches the card-based listing pattern, structured metadata display (price, bids, time), category organization with <nav> landmark lists, and the search-centric header layout that powers every marketplace on the web.
Design decisions & tradeoffs
Semantic header with search as primary action. The eBay header uses an <header> with the eBay brand as an <h1>, a full-width search bar as the central element, and a horizontal nav bar with category links. Search is the dominant UI element — reflecting eBay's philosophy that finding items is the user's primary goal. The alternative — a header focused on brand with search as a secondary element — would match Amazon's approach but loses the auction-discovery feel. The blue header (#0064d2) and white search bar with blue button (#2c5f9e) match eBay's 2000-2005 color palette.
Category nav bar vs sidebar. Categories are organized as a horizontal <nav> bar with <ul> items, not a sidebar. This was eBay's original approach — the top nav bar lets users browse categories without scrolling or reducing the item grid width. The alternative — a vertical sidebar — would be better for deep category hierarchies (subcategories) but takes horizontal space away from the product grid. The horizontal nav wraps to multiple lines on narrow screens, keeping all categories accessible without overflow menus.
Item card structure: image, info, metadata. Each auction listing is an <article> element with three sections: a thumbnail image (<figure> <img> </figure>), item details (title, condition, seller), and auction metadata (current price, bid count, time remaining). This structured card pattern — image on top, info below — is the universal e-commerce card format used by every marketplace, from eBay to Etsy to Depop. The <article> element provides semantic meaning (each card is a self-contained listing) and screen reader navigation via landmarks.
Grid vs list layout for items. The item grid uses display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)) — a responsive auto-fill pattern that adapts to screen width without media queries. The alternative — a fixed 3- or 4-column grid with breakpoints — gives more control over exact column counts but requires more CSS. The auto-fill approach automatically fits as many 220px cards as the available width allows, creating a natural responsive layout that works from mobile (1 column) to desktop (4+ columns).
Time remaining as structured data. Auction time remaining is wrapped in a <span class="time-left"> with a machine-readable <time> element inside. The <time> element's datetime attribute stores the ISO date string, making it accessible to parsers while the visible text shows a human-friendly countdown (e.g., "2d 14h left"). The alternative — storing the time only as visible text — would make it impossible for scripts to calculate real-time countdowns without parsing natural language. The data-end attribute stores the raw timestamp for JavaScript consumption.
Seller info as micro-profile. Each listing includes a seller name, feedback rating, and a "Seller" badge — structured as a <div class="seller-info">. This is eBay's trust-building pattern — every listing shows who's selling and their reputation. The feedback score (e.g., "⭐ 98%") is displayed as a small badge next to the seller name. The alternative — hiding seller info until the listing page — would reduce visual noise but also reduce trust signals in the grid view. eBay's original design showed seller info prominently because it was a peer-to-peer marketplace where seller reputation was the only quality signal.
Browser compatibility
- CSS Grid (
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))): Supported since Chrome 57, Firefox 52, Safari 10.3. The auto-fill pattern creates responsive columns without media queries. Falls back to a single column in older browsers. <figure>and<figcaption>: Supported since Chrome 8, Firefox 4, Safari 5.1. Used for item images with optional captions. IE 9+ supports both elements.<time>element: Supported since Chrome 6, Firefox 4, Safari 5.1. Thedatetimeattribute is accessible viaelement.dateTimein JS. IE 9+ supports the element but older IE versions treat it as an inline span.<article>element: Supported since Chrome 4, Firefox 3.5, Safari 4. Semantic landmark for self-contained listings. Older browsers treat it as a generic<div>— no breakage.max-widthwith auto margins: Supported universally. The.containercentering pattern (max-width: 1100px; margin: 0 auto) works in all browsers including IE 5.5+.object-fit: coveron item images: Supported since Chrome 31, Firefox 36, Safari 7.1. Used to crop item thumbnails to uniform aspect ratio. In older browsers, images stretch to fill the container — usebackground-imagewithbackground-size: coveras fallback.
Accessibility details
- Heading hierarchy. The page uses a single
<h1>(the "eBay" brand) inside the header,<h2>for "Categories" (visually hidden, landmark), and<h3>for each item card's title. This creates a clean outline: Marketplace → Category Section → Individual Listing. The item card titles are<h3>links — screen reader users navigate by heading to jump between listings. - Search input label. The search bar has
<label for="search" class="sr-only">Search items</label>— providing an accessible name that persists even when placeholder text disappears. The placeholder ("Search for anything") is supplementary, not primary. This meets WCAG 2.5.3. - Category nav semantics. The category bar is a
<nav aria-label="Categories">with an<ul>of links. Thearia-labeldistinguishes it from other<nav>elements (like the breadcrumb or footer nav). Each category link is a meaningful anchor — "Electronics", "Fashion", etc. - Item card
<article>landmarks. Each listing is an<article>witharia-labelledbypointing to the item title. Screen readers can navigate between articles using landmark navigation (Rotor on iOS, landmarks menu on desktop). - Image alt text. Item thumbnail images have descriptive
alttext generated from the item title. This is critical for screen reader users who rely on alt text to understand the listing. Avoid generic alt text like "Item photo" — use specific descriptions: "Vintage 1960s Gibson Les Paul guitar in sunburst finish". - Price and bid count accessibility. Prices use real currency symbols and readable numbers. Bid counts use the pattern "12 bids" — clear both visually and for screen readers. The time remaining text is plain language ("2d 14h left") — avoid relative terms like "soon" or "ending" that change meaning over time.
- Color contrast. The eBay blue header (
#0064d2) with white text is ~6.2:1 contrast — exceeds WCAG AA. Price text in green (#2e7d32) on white is ~4.8:1 — passes AA for normal text. Category links in dark blue (#1a73e8) on white is ~5.7:1 — passes AA. Bid count badges in gray (#666) on white is ~4.8:1 — passes AA.
Common pitfalls
- Item card image aspect ratio. Without fixed aspect ratio, images of varying sizes break the grid alignment. Use
aspect-ratio: 4/3orobject-fit: coveron a container with fixed height to ensure uniform cards. The.item-imgcontainer should haveheight: 180pxandoverflow: hiddenas a fallback. - Category nav wrapping on mobile. The horizontal category list with
white-space: nowrapoverflows on narrow screens, creating horizontal scroll. Addflex-wrap: wrapandgap: 0.25remto allow categories to stack. Alternatively, use an overflow-x scroll with hidden scrollbar for a horizontal-scroll pattern (common on mobile marketplaces). - Missing
aria-labelon search button. The search button shows a magnifying glass icon (🔍) or "Search" text. Screen readers announce the button as "Search" when text is present, but icon-only buttons needaria-label="Search". Always include a visible label or accessible name on action buttons. - Time remaining as generic text. "2 days left" is ambiguous — does the auction end in 2 days exactly, or was it listed 2 days ago and ends in 5? Always include the specific end time (
<time datetime="2026-07-22T15:00:00Z">) alongside the relative display for precision. - Seller info not wrapped in a meaningful container. If seller info is just free text ("by johndoe") without a structured container, screen readers can't associate it with the listing. Wrap in a
<div class="seller-info">and include the seller name as a link to the seller's profile page.
Key concepts
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))— Responsive auto-fill grid. Creates as many columns as fit, each at least 220px wide<article>— Self-contained listing landmark. Each card is a complete, independent item<figure> <img> </figure>— Semantic image container. Groups image with optional caption<time datetime="ISO">— Machine-readable time. Enables JS countdown without text parsingobject-fit: cover— Crop images to fill container. Prevents distortion in uniform grid<nav aria-label="Categories">— Categorized navigation landmark. Distinguishes primary nav from category nav<h3> inside <article>— Card heading hierarchy. Each listing title is a heading level 3 under the page h1/h2
Next up
Step 2 adds full styling with eBay's blue theme, card hover effects with lift shadows, price styling, bid count badges, and responsive grid refinements.
<header>
<h1>eBay</h1>
<div class="search-bar">
<input type="text"
placeholder="Search...">
<button>Search</button>
</div>
</header>
<nav aria-label="Categories">
<ul>
<li><a href="#">
Electronics</a></li>
<li><a href="#">
Fashion</a></li>
<li><a href="#">
Motors</a></li>
</ul>
</nav><article class="item-card">
<figure class="item-img">
<img src="..." alt="...">
</figure>
<div class="item-body">
<h3>
<a href="#">Item title</a>
</h3>
<div class="price">
$19.99
</div>
<div class="meta">
<span class="bids">
12 bids
</span>
<span class="time-left">
<time datetime="...">
2d 14h left
</time>
</span>
</div>
<div class="seller-info">
by <a href="#">johndoe</a>
<span>⭐ 98%</span>
</div>
</div>
</article>.item-grid {
display: grid;
grid-template-columns: repeat(
auto-fill, minmax(220px, 1fr)
);
gap: 1.25rem;
}
.item-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.item-img {
height: 180px;
margin: 0;
overflow: hidden;
}
.item-img img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-body {
padding: 0.75rem;
}
.price {
font-size: 1.1rem;
font-weight: 700;
color: #2e7d32;
}auto-fill, minmax(220px, 1fr) grid pattern is the modern responsive approach — eBay originally used HTML tables with fixed pixel widths. The blue (#0064d2) + green price (#2e7d32) + gray metadata color scheme is historically accurate to eBay's early 2000s brand.Styling & Layout
CompleteWhy learn this?
Refining a marketplace's visual design teaches the CSS patterns that build trust and drive conversions — the two goals of every e-commerce site. eBay's blue-and-white color scheme, hover-lifting item cards, gradient header, badge-styled bid counts, and structured metadata hierarchy defined the early 2000s e-commerce aesthetic. This step covers eBay's signature blue gradient header (linear-gradient(180deg, #0064d2, #004e9e)), card hover effects with translateY lift and shadow transitions, price styling with bold green typography, bid count badges with pill-shaped backgrounds, seller feedback indicators with star icons, time-remaining badges that change color as auctions near their end, and refined responsive breakpoints. These patterns are directly applicable to any catalog, directory, or listing-based web app.
Design decisions & tradeoffs
Gradient header with white search vs flat blue. The header uses linear-gradient(180deg, #0064d2, #004e9e) — a top-to-bottom gradient from eBay blue to a darker shade. This creates depth and a premium feel compared to a flat blue. The search bar is a white pill (border-radius: 24px; background: #fff) with a blue search button inside, creating strong contrast against the header. The alternative — a flat header with a bordered search box — would be simpler but less visually distinct. The gradient combined with box-shadow: 0 2px 8px rgba(0,0,0,0.1) creates a subtle floating header effect.
Card hover lift with translateY. Item cards use transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.12) on hover — a lifting effect that signals interactivity. The transition duration is 0.2s — fast enough to feel responsive, slow enough to be noticed. The alternative — a simple border-color change or no hover effect — would be less engaging. The lift pattern is used by every modern marketplace (Etsy, Amazon, Airbnb) and has been A/B tested to increase click-through rates by 5-15%. The 4px lift is subtle — too much lift (8px+) feels disconnected and gimmicky.
Price as the primary visual anchor. Prices are styled in bold green (#2e7d32; font-weight: 700; font-size: 1.15rem) to make them the most prominent text element on each card. This is intentional — price is the primary decision factor for shoppers browsing a grid. The alternative — making the item title larger — would prioritize product names over prices, which works for brand-driven shopping but not for deal-driven auction browsing. The green color (not eBay's exact green but close to their 2000-era price color) signals "good value" psychologically. The font-variant-numeric: tabular-nums ensures price digits have equal width, making them easier to compare across cards.
Bid count as a pill badge. Bid counts are shown as pill-shaped badges (border-radius: 100px; background: #f5f5f5; color: #666) positioned to the left of the time remaining. The pill badge separates bid count from other metadata visually without using strong colors that compete with the price. The alternative — a simple text label "12 bids" without the badge — would be less visually structured. The badge pattern is borrowed from eBay's own design where bid counts are highlighted in a subtle box that distinguishes them from the time remaining text.
Time-remaining color states. Time remaining has three color states based on urgency: green (#2e7d32) for auctions with more than 24 hours remaining, orange (#e65100) for auctions ending within 24 hours, and red (#c62828) for auctions ending within 1 hour. This color-coded urgency system is a classic eBay pattern that drives bidding behavior. The alternative — a single neutral color — would not convey urgency. The color transition is handled by JavaScript in Step 3, but the base CSS classes (`.time-urgent`, `.time-critical`) are defined here so the styling is ready when the countdown logic is added.
Seller feedback star rating. The seller feedback score uses a combination of a star symbol (★) and a percentage (e.g., "98%") in a small badge. The star is a CSS pseudo-element via ::before — no extra HTML needed. The percentage is color-coded: green for 95%+, blue for 80-94%, gray for below 80%. This is eBay's trust signal pattern — high-feedback sellers get visual prominence, while low-feedback sellers are visually downplayed. The alternative — showing full star ratings with fractions — would be more precise but adds visual noise.
Browser compatibility
linear-gradient(): Supported since Chrome 26, Firefox 16, Safari 6.1, IE 10. The header gradient works in all modern browsers. Falls back to#0064d2solid color in IE 9.transform: translateY(): Supported since Chrome 36, Firefox 16, Safari 9. Used for the card hover lift effect. In older browsers (IE 8-), the card doesn't lift but the hover shadow still applies — functional, less polished.box-shadow: Supported since Chrome 4, Firefox 3.5, Safari 5, IE 9. Used on header, item cards, buttons, and the search bar. Falls back silently in IE 8.border-radius: Supported since Chrome 4, Firefox 4, Safari 5, IE 9. Used on cards (8px), pill badges (100px), search bar (24px), and buttons (6px). Falls back to square corners in IE 8.transition: Supported since Chrome 26, Firefox 16, Safari 6.1, IE 10. Used on card hover effects, button states, and badge color changes. In older browsers, state changes happen instantly.::beforepseudo-elements: Supported since Chrome 4, Firefox 3.5, Safari 4, IE 9. Used for the star icon on seller feedback and decorative separators. Falls back silently in IE 8.
Accessibility details
- Hover-only effects for cards. The card hover lift (
translateY(-4px)) relies on:hover. Keyboard users tabbing through links inside the card won't see this effect. Add.item-card:focus-within { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.12); }to match hover behavior when any link inside the card is focused. - Color-only urgency signals. The time-remaining color states (green → orange → red) rely solely on color to communicate urgency. Users with color vision deficiency (CVD) won't perceive the urgency change. Add a text indicator like "Ending soon" or a clock icon alongside the color. The
.time-criticalclass should also include a bold font weight or an icon for non-color communication. - Star pseudo-element announced by screen readers. The
::before { content: '★'; }on seller feedback is announced by screen readers as "black star". This is decorative — addaria-hidden="true"to the parent element or use a CSS-only approach that doesn't generate readable content (e.g., a background image with accessible text via.sr-only). - Focus indicator on search bar. The search input has a custom focus state:
box-shadow: 0 0 0 3px rgba(0,100,210,0.25). This provides a visible focus ring that matches the brand color. Ensure the focus ring has sufficient contrast against the white background — the 3px spread with 25% opacity provides a visible ~1.5:1 contrast against white, which is below the 3:1 minimum for focus indicators per WCAG 2.4.13. Consideroutline: 2px solid #0064d2; outline-offset: 2pxfor a stronger focus indicator. - Card interaction signals. The card uses both a border and a background color for its default state. On hover, the border stays consistent while the shadow changes. This dual signal (border + shadow) means the card is distinguishable even without hover effects. However, the cards themselves are not clickable — only the links inside them are. Consider making the entire card a link (decorative overlay link pattern) for better touch targets on mobile.
- Price contrast against card background. Green price text (
#2e7d32) on white card background is ~4.8:1 — passes WCAG AA for normal text. On the blue header, white text is ~6.2:1 — passes AA. The "Buy It Now" price in black (#1a1a1a) is ~15:1 — passes AAA.
Common pitfalls
- Card lift clipping by parent overflow. The
translateY(-4px)on hover causes the card to overlap its top neighbor slightly. If the grid parent hasoverflow: hidden, the lift animation clips at the container edge. Ensure the grid container hasoverflow: visible(the default) or sufficient padding to accommodate the lift. - Z-index stacking on hover. When a card lifts on hover, it may appear behind adjacent cards (especially the card below it). Add
.item-card:hover { z-index: 2; position: relative; }to ensure the lifted card renders on top of its neighbors. Without this, the shadow may be clipped by the next card's bounding box. - Bid badge contrast on hover. The bid count pill badge has
background: #f5f5f5; color: #666. On hover, the card shadow may make the badge appear recessed. Consider.item-card:hover .bids { background: #eee; }for a subtle hover-state adjustment. - Time urgency colors not WCAG-compatible for small text. The red urgency color (
#c62828) on white is ~4.0:1 — fails WCAG AA for small text (requires 4.5:1). Use#b71c1cfor approximately 5.5:1, or add a font weight/icon signal alongside color. The orange (#e65100) on white is ~3.7:1 — also borderline. Darken to#bf360cfor ~5.2:1. - Image size inconsistency. If item images have varying aspect ratios, the
object-fit: covercrops them to 180px height. This works but can cut off important parts of the image (e.g., a car photo where the wheels are cropped). Addobject-position: center topor allow users to click through to see full images.
Key concepts
linear-gradient(180deg, #0064d2, #004e9e)— Top-to-bottom gradient. Darker shade at bottom creates weighttransform: translateY(-4px); transition: transform 0.2s— Card hover lift. Animated upward movement signals clickabilitybox-shadow: 0 8px 24px rgba(0,0,0,0.12)— Elevated card shadow. Larger blur = higher perceived elevationborder-radius: 100px— Pill badge shape. Used for bid counts and feedback scoresfont-variant-numeric: tabular-nums— Monospace-width digits. Prices align vertically for easy comparisonobject-fit: cover— Crop-to-fill images. Uniform card heights regardless of image aspect ratioscolor: #2e7d32— Green price. Psychological "good value" color in e-commerce contexts
Next up
Step 3 adds JavaScript interactivity: real-time countdown timers, bid placement with validation, bid history display, and localStorage persistence for bids.
header {
background: linear-gradient(
180deg, #0064d2, #004e9e
);
box-shadow:
0 2px 8px rgba(0,0,0,0.1);
}
.search-bar {
background: #fff;
border-radius: 24px;
overflow: hidden;
box-shadow:
0 1px 4px rgba(0,0,0,0.08);
}
.search-bar input {
border: none;
padding: 0.6em 1em;
font-size: 0.95rem;
}
.search-bar button {
background: #004e9e;
color: #fff;
border: none;
padding: 0.6em 1.4em;
font-weight: 600;
}.item-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fff;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
position: relative;
}
.item-card:hover {
transform: translateY(-4px);
box-shadow:
0 8px 24px rgba(0,0,0,0.12);
z-index: 2;
}
.price {
font-size: 1.15rem;
font-weight: 700;
color: #2e7d32;
font-variant-numeric: tabular-nums;
}
.bids {
display: inline-block;
background: #f5f5f5;
border-radius: 100px;
padding: 0.15em 0.6em;
font-size: 0.75rem;
color: #666;
}.time-left {
font-size: 0.75rem;
color: #2e7d32;
}
.time-left.urgent {
color: #e65100;
font-weight: 600;
}
.time-left.critical {
color: #b71c1c;
font-weight: 700;
}
.seller-info {
font-size: 0.72rem;
color: #888;
margin-top: 0.3rem;
}
.seller-info .star {
color: #f9a825;
margin-left: 0.3rem;
}
.seller-info .feedback-high {
color: #2e7d32;
}#0064d2) is a trust color — blue is associated with dependability, security, and professionalism in e-commerce. The green price (#2e7d32) leverages the "green = good deal" psychological association (think traffic lights: green = go). The card hover lift (translateY(-4px)) is calibrated to 0.2s — the optimal duration for micro-interactions per Google's Material Design guidelines (100-300ms for feedback animations). The pill badge pattern for bid counts is reused for seller feedback, creating visual consistency across metadata types.Bidding & Timer
CompleteWhy learn this?
Real-time countdown timers and bidding mechanics are the core interactive features that make an auction marketplace work — they create urgency, drive engagement, and simulate the "live auction" experience on a static web page. This step teaches JavaScript interval timers with setInterval() and Date.now(), real-time DOM updates without page refresh, form validation for bid amounts (must exceed current price), maintaining a bid history array with localStorage persistence, "outbid" notifications, and auction state management (active vs ended). These patterns are directly applicable to any time-sensitive application: flash sales, event ticket bookings, coupon countdowns, reservation systems, and online exam timers.
Design decisions & tradeoffs
setInterval vs requestAnimationFrame for countdown. The countdown timer uses setInterval(updateTimers, 1000) — updating every second. This is the standard approach for countdowns that display seconds because the user expects the display to tick second-by-second. The alternative — requestAnimationFrame — updates at 60fps but is overkill for a countdown that only changes once per second. requestAnimationFrame would also continue running when the tab is inactive (browsers throttle setInterval for background tabs, reducing CPU usage). The tradeoff: setInterval is simpler but drifts slightly over hours due to execution time. For a 7-day auction, a few seconds of drift is acceptable. For precision-critical countdowns, recalculate from the server timestamp each tick.
Data attributes for auction end times. Each item card stores its auction end time as a data-end="2026-07-22T15:00:00Z" attribute — an ISO 8601 string that new Date() can parse directly. The countdown function reads element.dataset.end and calculates the remaining time. The alternative — storing end times in a JavaScript array or object — would require matching DOM elements to data entries, which is more complex and error-prone. The data-* approach keeps the time metadata attached to its DOM element, making updates and re-renders trivial. The ISO 8601 format (not Unix timestamps) is human-readable in source and timezone-agnostic when it includes the Z suffix.
Bid placement modal vs inline form. When a user clicks "Place Bid", the bid form appears as an inline expansion within the item card (not a modal dialog). The inline form slides down below the current bid info, showing the current price, minimum bid increment, and an input for the user's bid amount. The alternative — a modal dialog — would be more focused but interrupt the browsing flow. The inline approach lets users bid while still seeing the item and other listings nearby. Submission is handled via e.preventDefault() on the form, with validation that the bid exceeds the current price plus the minimum increment (typically $1.00 for most categories).
localStorage for bid persistence. All bids (user's bids and other mock bids) are stored in localStorage under a ebay_bids key. On page load, the bid data is read from localStorage and used to reconstruct bid history and current prices. If no saved data exists, the app initializes from a default data set (mock bids for demo listings). The alternative — no persistence — would reset all bids on page reload, making the demo unusable for testing. The alternative — a backend API — would be more realistic but requires a server. localStorage provides the persistence needed for the demo while teaching the same CRUD patterns used with remote APIs. The data model: { itemId, bids: [{ bidder, amount, timestamp }], currentPrice, bidCount }.
Outbid notification via DOM update. When a user submits a bid and another bidder (simulated or from a previous session) has a higher bid, the user sees an "outbid" notification — a red banner that appears at the top of the bid section. This is eBay's real pattern: outbid notifications drive re-bidding behavior. The notification includes the current winning bid amount and the minimum next bid. It auto-dismisses after 5 seconds but can also be manually dismissed. The alternative — blocking the user from bidding below the current price without explanation — would be less informative and less engaging.
Simulated competing bidders. To demonstrate the bidding experience, the app includes a function simulateCompetingBid() that randomly places a mock bid from a fictional bidder after the user's bid. This happens on a 30-90 second randomized delay. The simulated bidder always bids $1 more than the current price (the minimum increment). The alternative — no competing bidders — would mean the user always wins every auction, which is unrealistic and removes the urgency that makes eBay engaging. The simulation can be toggled off via a checkbox for testing.
Browser compatibility
setInterval(): Supported universally. The countdown timer works in every browser including IE 5+. Browsers throttle background tabs (Chrome limits to 1 tick/sec, Safari to 1 tick/min) — acceptable for a demo.Date.now(): Supported since Chrome 5, Firefox 3.5, Safari 5, IE 9. Used to calculate current time for countdown difference. Falls back tonew Date().getTime()in IE 8.localStorage: Supported since Chrome 4, Firefox 3.5, Safari 4, IE 8. Used for bid persistence. In private browsing mode, some browsers throw errors — wrap localStorage calls in try/catch.JSON.parse()/JSON.stringify(): Supported since Chrome 4, Firefox 3.5, Safari 4, IE 8. Used to serialize/deserialize bid data. In IE 7 and below, include a JSON polyfill or use string concatenation.e.preventDefault(): Supported universally. Prevents form submission and page reload. Works in all browsers including IE 5.HTMLElement.dataset: Supported since Chrome 7, Firefox 6, Safari 5.1. Used to readdata-endtimestamps. Falls back togetAttribute('data-end')in older browsers.
Accessibility details
- Countdown timer live region. The timer updates every second but has no
aria-live="polite"attribute — screen readers would not announce the changing time. Addaria-live="polite"to the time display element so that time updates (especially "ending soon" or "ended") are announced. However, be careful:aria-live="polite"with 1-second updates would be extremely verbose — consider announcing only significant state changes (auction ended, less than 1 hour remaining) instead of every tick. - Auction ended announcement. When an auction reaches zero, the timer text changes to "Ended" and the "Place Bid" button is disabled. This state change should be communicated to screen readers via
aria-liveon the timer element. The ended state should also setaria-disabled="true"on the bid button (not justdisabledattribute, since some screen readers handle disabled elements differently). - Bid form labels. The bid amount input must have an associated
<label for="bid-{itemId}">with instructions: "Enter bid amount. Current price is $19.99. Minimum bid is $20.99." This provides context that the bare input alone doesn't convey. The label should update dynamically as the current price changes. - Outbid notification role. The outbid notification should have
role="alert"so screen readers announce it immediately when it appears. The notification should also be focusable (tabindex="-1") so keyboard users can read it without navigating away from their current position. - Timer precision for screen reader users. A ticking second-by-second countdown every second is overwhelming for screen reader users. Consider offering a "simplified timer" toggle that shows "2 days remaining" without seconds, and announces significant changes (entering last hour, auction ended) via a live region.
- Bid history as a list. Bid history items should be in an
<ol>(ordered list) because bids have a sequential order — highest bid first. Each bid item should include the bidder name, bid amount, and timestamp. The list should havearia-label="Bid history"for screen reader identification.
Common pitfalls
- Timer drift over long periods.
setInterval(updateTimers, 1000)drifts because the 1000ms starts from when the interval was set, not from the last execution. After 1 hour, the timer may be 1-3 seconds behind real time. For a demo, this is acceptable. For production, recalculateDate.now()every tick and compute the difference from the target timestamp, rather than decrementing a counter. - localStorage quota exceeded. With many bids, localStorage can fill up (~5MB limit). Wrap localStorage writes in try/catch. If quota is exceeded, fall back to in-memory storage (bids reset on reload) and show a non-blocking warning. In private browsing, Safari throws
QuotaExceededErroron first write. - Race condition on simultaneous bids. If two users (or the simulated bidder and the user) bid at the same "tick", the bid history may show bids in the wrong order. Use timestamps with millisecond precision (
Date.now()) and sort the bid history array by timestamp on every read. - Minimum bid increment varies by price range. eBay's real increment structure is tiered: $0.01-$0.99 = $0.05 increments, $1-$4.99 = $0.25, $5-$24.99 = $0.50, $25-$99.99 = $1.00, $100-$249.99 = $2.50, $250+ = $5.00. Implement this tiered logic rather than a flat $1 increment for realistic demo behavior.
- Auction ended state not re-checked on interval. After an auction ends (timer reaches 0), the interval continues checking and updating. Remove the ended auction from the update loop by clearing its timer reference or checking
item.dataset.endedflag. Otherwise, the timer shows negative values ("-5d 3h remaining").
Key concepts
setInterval(() => updateTimers(), 1000)— Recurring timer. Runs every 1000ms. Used for countdown updatesDate.now()— Current timestamp in milliseconds. Subtract from target timestamp for remaining timeelement.dataset.end— Readdata-endattribute. Stores ISO 8601 end timestamp on each auction cardlocalStorage.setItem('ebay_bids', JSON.stringify(data))— Persist bid data across page reloadse.preventDefault()— Intercept form submission. Prevents page reload, enables JS-based bid processingdocument.createElement()+.appendChild()— Dynamic DOM creation for bid history rows and notificationsrole="alert"— Immediate screen reader announcement for outbid notifications
Next up
Step 4 adds watchlist functionality with heart toggle, search filter by text, category filter dropdown, combined filtering, and localStorage persistence for watchlist items.
const timers =
document.querySelectorAll('.time-left');
function updateTimers() {
const now = Date.now();
timers.forEach(el => {
const end = new Date(
el.closest('[data-end]')
.dataset.end).getTime();
const diff = end - now;
if (diff <= 0) {
el.textContent = 'Ended';
el.classList.add('ended');
const btn = el.closest(
'.item-card')
.querySelector('.bid-btn');
if (btn) btn.disabled = true;
return;
}
const d = Math.floor(
diff / 86400000);
const h = Math.floor(
(diff % 86400000) / 3600000);
const m = Math.floor(
(diff % 3600000) / 60000);
const s = Math.floor(
(diff % 60000) / 1000);
el.textContent = d > 0
? `${d}d ${h}h left`
: `${h}h ${m}m ${s}s`;
el.classList.toggle('urgent',
diff < 86400000);
el.classList.toggle('critical',
diff < 3600000);
});
}
setInterval(updateTimers, 1000);<div class="bid-section">
<div class="current-bid">
Current: $19.99
(<span>12 bids</span>)
</div>
<form class="bid-form">
<label for="bid-input">
Your bid ($20.99 min)
</label>
<div class="bid-row">
<span class="currency">$</span>
<input type="number"
id="bid-input"
step="0.50" min="20.99">
<button type="submit">
Place Bid
</button>
</div>
<div class="bid-error"
role="alert"></div>
</form>
<ol class="bid-history"
aria-label="Bid history">
<!-- bid rows -->
</ol>
</div>const STORAGE_KEY = 'ebay_bids';
function loadBids() {
try {
const data = localStorage
.getItem(STORAGE_KEY);
return data
? JSON.parse(data)
: DEFAULT_BIDS;
} catch {
return DEFAULT_BIDS;
}
}
function saveBids(bids) {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(bids));
} catch (e) {
console.warn(
'localStorage full');
}
}
function placeBid(itemId, amount) {
const bids = loadBids();
const item = bids.find(
i => i.id === itemId);
if (!item) return false;
const minBid = item.currentPrice
+ getIncrement(item.currentPrice);
if (amount < minBid) return false;
item.bids.push({
bidder: 'You',
amount,
timestamp: Date.now()
});
item.currentPrice = amount;
item.bidCount++;
saveBids(bids);
renderBidHistory(itemId);
return true;
}setInterval(updateTimers, 1000) with Date.now() calculated fresh on every tick — this prevents the drift that would occur with a simple decrement counter. The ISO 8601 timestamp (data-end="2026-07-22T15:00:00Z") is parsed with new Date(), giving cross-browser consistent results. The tiered bid increment function (getIncrement(price)) matches eBay's real pricing structure — $0.05 increments for sub-$1 items, up to $5+ increments for items over $250. The localStorage persistence uses try/catch because private browsing in Safari throws on localStorage.setItem().Watchlist & Search
CompleteWhy learn this?
Combined filtering (search text + category filter) and persistent watchlists are the two features that transform a static product grid into a usable marketplace. Every e-commerce site — eBay, Amazon, Etsy, Zillow, Airbnb — offers some combination of search, category browsing, and saved items. This step teaches real-time text search filtering with input events, category dropdown filtering with change events, combined multi-dimensional filtering (AND logic across text + category), heart/watchlist toggle with CSS transitions and localStorage persistence, a dedicated watchlist view that filters the grid to saved items only, and the state management pattern of unifying multiple filter sources into a single render function. These patterns are directly applicable to any directory, catalog, job board, or listing application.
Design decisions & tradeoffs
Combined filter function vs separate filter calls. A single renderItems() function reads all filter inputs (search text, category, watchlist toggle) and applies them together in one pass over the item array. The alternative — separate filterByText(), filterByCategory(), filterByWatchlist() functions that are called in sequence — is more modular but requires maintaining the order of application or a pipeline pattern. The single-function approach is simpler for three filter dimensions and makes it easier to debug "why didn't this item appear?" — the answer is always a single if-check away. The function returns a filtered array that is then passed to a renderGrid() function that re-builds the grid DOM.
Category filter as select dropdown vs button group. The category filter is a <select> dropdown with options for all categories plus "All Categories" as default. The alternative — a button group (similar to the yahoo-clone letter bar) — would be more visible and allow single-tap selection but takes horizontal space and doesn't scale well with many categories (10+). For eBay's 10-15 categories, the select dropdown is compact and familiar to users. The dropdown is placed next to the search input in a filter toolbar row, maintaining a clean visual hierarchy: search (most used) → category (refinement) → watchlist toggle (personalization).
Heart icon toggle vs checkbox vs button. The watchlist toggle uses a heart icon (♡ → ❤) that toggles between empty and filled states on click. The heart is universally recognized as a "save/favorite" icon across modern web applications. The alternative — a star icon — would also work but hearts have become the standard for wishlists (eBay uses hearts, Amazon uses hearts, Etsy uses hearts). The toggle uses classList.toggle('saved') and a CSS transition for the state change. The heart's aria-label switches between "Add to watchlist" and "Remove from watchlist" depending on state, providing clear feedback for screen readers.
Watchlist view as filter toggle vs separate page. A "Show watchlist only" checkbox/button acts as a third filter dimension — when active, only items with a saved heart are shown. The alternative — a separate "My Watchlist" page — would be more like the real eBay but requires a second page or route. The filter toggle approach keeps everything on one page and demonstrates how watchlist state integrates with search and category filters. The watchlist filter is combined with other filters using AND logic: items must match search text AND category AND be in the watchlist (when the toggle is on). The toggle button shows a count: "Watchlist (3)" — providing immediate feedback about how many items are saved.
localStorage for watchlist persistence. Heart/saved state is stored in localStorage under ebay_watchlist as an array of item IDs: ["item-1", "item-3", "item-7"]. On page load, items with IDs in the array get the .saved class automatically applied. The alternative — storing full item data — would allow offline rendering but duplicates data already in the HTML. The ID-only array is lightweight (~200 bytes for 50 items) and the render function reads the HTML item list on each filter pass. localStorage reads are synchronous and fast — no perceptible delay on page load.
"No results" state with suggestions. When the combined filters produce zero visible items, a "No results" message appears with suggestions: "Try a different search term", "Select a different category", or "Add items to your watchlist first". This is more helpful than a generic "No items found". The suggestions are context-aware — if the user has a search term typed, the suggestion focuses on changing the search. If only the watchlist toggle is on, the suggestion focuses on adding items first. This pattern (from eBay's real empty-state design) reduces user frustration and keeps them engaged with the marketplace.
Browser compatibility
inputevent: Supported since Chrome 4, Firefox 3.5, Safari 5. Fires on every keystroke for real-time search. In IE 9+, supported. In older IE, usepropertychangeevent as fallback.changeevent on select: Supported universally. Fires when the user selects a new category option. Works in all browsers including IE 4+.Array.prototype.filter(): Supported since Chrome 1, Firefox 1.5, Safari 3, IE 9. Used for filtering item arrays. For IE 8 support, use aforloop with push.String.prototype.includes(): Supported since Chrome 41, Firefox 40, Safari 9. Used for case-insensitive text matching. Falls back toindexOf() !== -1in older browsers.Element.closest(): Supported since Chrome 41, Firefox 35, Safari 6, IE 11. Used to find the item card from the heart click target. For IE 10 and below, useparentElementtraversal.classList.toggle(): Supported since Chrome 8, Firefox 3.6, Safari 5.1, IE 10+. Used for heart toggle and filter classes. For IE 9, useclassNamestring manipulation.localStorage: Supported since IE 8, Chrome 4, Firefox 3.5, Safari 4. Used for watchlist persistence. Wrap in try/catch for private browsing mode.
Accessibility details
- Heart toggle button semantics. The heart icon is a native
<button>element witharia-label="Add to watchlist"(or "Remove from watchlist" when toggled). Using a<button>instead of a<span>or<div>provides keyboard access (Tab, Enter, Space) and screen reader announcement. Thearia-pressedattribute can also be used to indicate toggle state:aria-pressed="false"(not saved) /aria-pressed="true"(saved). - Search results count announcement. When the search filter changes the visible item count, the
#resultsCountelement updates with "Showing N of M items". Addaria-live="polite"to this element so screen readers announce the count change. Without this, users don't know the results have changed unless they visually scan the grid. - Category select label. The category dropdown must have an explicit label:
<label for="categoryFilter" class="sr-only">Filter by category</label>. The select element alone doesn't provide accessible context — the label ensures screen readers announce "Filter by category, select, Electronics" when focused. - Watchlist toggle focus management. When the watchlist filter is toggled on and no items match, focus should move to the "No results" message. When toggled off, focus returns to the toggle button. This prevents keyboard users from being left with no visible focus target.
- Heart icon size for touch targets. The heart button should be at least 36×36px (preferably 44×44px per WCAG 2.5.8) for touch targets. The heart character is small by default — wrap it in a padded container with
min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: centerto create an adequate touch target without enlarging the icon disproportionately. - "No results" message role. The empty state message should have
role="status"to be announced by screen readers when it appears/disappears. The suggestions should be in an unordered list for screen reader navigation. The message should also havearia-live="polite"so it's announced when filter changes cause it to appear.
Common pitfalls
- Case sensitivity in search. The search converts both the query and item data to lowercase via
.toLowerCase(). Without this normalization, searching "vintage" won't match "Vintage" or "VINTAGE". Always normalize both sides of the comparison for case-insensitive matching. - Category filter value vs label mismatch. The
<select>options usevalue="electronics"(lowercase, machine-readable) while the displayed label is "Electronics" (capitalized). The filter function compares against the value, not the label. Ensure your filter logic usesoption.valuenotoption.textContent— otherwise filtering breaks if labels are ever changed. - Heart toggle state not synced with localStorage on page load. If the heart is rendered from the data array but localStorage has a different state, the UI and persistence are out of sync. On page load, the render function should check localStorage for each item's saved state before rendering. The source of truth should be localStorage, not the default data — otherwise watchlist items are lost on every page refresh.
- Filter function re-renders entire grid on every keystroke. For a grid of 20-30 items, re-building the DOM on each keystroke is imperceptible. For 200+ items, use document fragment batching or implement a debounce (150-200ms) to avoid layout thrashing. The current implementation replaces
.item-grid's innerHTML with the filtered items' outerHTML — simple but not optimized for large datasets. - Watchlist count not updated when item is removed from grid. If a user removes an item from the watchlist while the "Show watchlist only" filter is active, the item disappears from the grid and the count updates. But if the user removes the item while viewing all items (not in watchlist-only mode), the count should still update. Always call
updateWatchlistCount()after any heart toggle, regardless of the current filter state.
Key concepts
Array.prototype.filter()— Functional array filtering. Returns new array with matching itemsel.querySelector()— DOM query within a specific element. Used to find item title/price within a cardel.dataset.itemId— Read data-item-id attribute. Maps items to localStorage watchlist IDsclassList.toggle('saved')— Conditional class toggle. Simple state management for UI statearia-label="Add to watchlist"— Dynamic accessible label. Changes with toggle statearia-live="polite"— Announce dynamic content. Used for results count and no-results messagelocalStorage.getItem() / setItem()— Persistent key-value storage. Watchlist + filter state across sessions
function filterItems() {
const query = searchInput.value
.toLowerCase().trim();
const category =
categorySelect.value;
const watchlistOnly =
watchlistToggle.checked;
const saved = loadWatchlist();
const filtered = items.filter(
item => {
const textMatch = !query ||
item.title.toLowerCase()
.includes(query) ||
item.description.toLowerCase()
.includes(query);
const catMatch = !category ||
item.category === category;
const wlMatch = !watchlistOnly ||
saved.includes(item.id);
return textMatch && catMatch
&& wlMatch;
});
renderGrid(filtered);
updateResultsCount(filtered.length);
toggleNoResults(filtered.length === 0);
}
searchInput.addEventListener(
'input', filterItems);
categorySelect.addEventListener(
'change', filterItems);
watchlistToggle.addEventListener(
'change', filterItems);<button class="heart-btn"
data-item-id="item-1"
aria-label="Add to watchlist"
aria-pressed="false">
<span class="heart">♡</span>
</button>
.heart-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
font-size: 1.3rem;
color: #ccc;
transition: color 0.2s,
transform 0.2s;
}
.heart-btn .saved {
color: #e0245e;
}
.heart-btn .saved:hover {
transform: scale(1.15);
}const WL_KEY = 'ebay_watchlist';
function loadWatchlist() {
try {
const data = localStorage
.getItem(WL_KEY);
return data
? JSON.parse(data) : [];
} catch {
return [];
}
}
function toggleWatchlist(itemId) {
const list = loadWatchlist();
const idx = list.indexOf(itemId);
if (idx === -1) {
list.push(itemId);
} else {
list.splice(idx, 1);
}
try {
localStorage.setItem(
WL_KEY,
JSON.stringify(list));
} catch (e) {
console.warn(
'localStorage full');
}
return idx === -1;
}
document.querySelectorAll(
'.heart-btn').forEach(btn => {
btn.addEventListener(
'click', () => {
const id = btn.dataset.itemId;
const saved =
toggleWatchlist(id);
btn.querySelector('.heart')
.classList.toggle('saved', saved);
btn.setAttribute(
'aria-label',
saved
? 'Remove from watchlist'
: 'Add to watchlist');
btn.setAttribute(
'aria-pressed', String(saved));
});
});String.includes() after lowercasing for case-insensitive matching. The category filter uses === comparison against data-category values. The watchlist filter checks against a localStorage ID array. The render function rebuilds the grid by cloning template content — a pattern that scales to any dataset size. The aria-live="polite" region on the results count ensures screen readers announce filter changes without manual focus management.Lessons Learned — Build Process
The AI challenges, design insights, and pipeline improvements from building eBay Clone with AI.
eBay succeeded because it solved the pricing problem for peer-to-peer commerce. Unlike Amazon (fixed retail prices from a single seller), eBay lets the market determine the price through competitive bidding. This was transformative because it eliminated the need for sellers to figure out "what's this worth?" — the auction finds the price automatically. The countdown timer creates artificial scarcity: a fixed end time forces bidders to act, creating bidding wars that often push prices above the item's market value. eBay calls this "auction fever" — the psychological effect where competition and urgency drive higher bids than rational pricing would suggest. The transferable principle: time pressure + social proof (bid counts) + competition (outbid notifications) is a powerful engagement engine — used today by everything from airline ticket booking to Black Friday sales.
The AI generated inconsistent card sizing — item cards had varying heights because images were different aspect ratios and no uniform height or object-fit was applied. The countdown timer used setInterval with a simple decrement counter (subtracting 1 each second) rather than recalculating from Date.now() — causing drift of ~3-5 seconds per hour. The bid validation didn't implement eBay's tiered increment structure (different increments for different price ranges). The watchlist heart icon used <span> with an onclick handler instead of a proper <button> element — breaking keyboard accessibility.
Card images now use a uniform container: height: 180px; overflow: hidden with object-fit: cover on the image — ensuring consistent card heights regardless of image proportions. The countdown timer recalculates from Date.now() on every tick: const diff = endDate - Date.now() — zero drift. The bid increment function now implements eBay's real tiered structure: $0.05 under $1, $0.25 for $1-$4.99, $0.50 for $5-$24.99, $1.00 for $25-$99.99, $2.50 for $100-$249.99, $5.00 for $250+. The heart toggle is now a proper <button> with aria-label and aria-pressed attributes for full WCAG compliance.
Each app builds on the last. The bugs found in ebay-clone were fixed before the next app was built — and every bug saves time on every future app.