1994 Web Portal — Category directory, link lists, search box, news feed, email login mock. Yahoo started in 1994 as "Jerry's Guide to the World Wide Web" — a simple directory of hand-picked websites organized by category. It became the first web portal and taught the world what a homepage could be. Inspired by Yahoo! circa 1996 and the evolution of web portals into today's browser homepages and search engines.

01

HTML Structure

Complete

Why learn this?

Web portals were the first version of the "homepage" — a single page that acted as a gateway to everything on the internet. Yahoo!, founded in 1994 by Jerry Yang and David Filo, started as a hand-curated directory of websites organized by category. This pattern — a header with branding, a search box, a category grid, news feeds, and sidebar widgets — became the blueprint for every portal that followed: AOL, MSN, Excite, and even modern browser start pages. Building a Yahoo! clone teaches the grid-based category layout, search input patterns, sidebar content organization, and the portal information hierarchy that still powers news sites and dashboards today.

Design decisions & tradeoffs

Semantic HTML header vs logo image. The Yahoo! brand uses a styled <h1> with text "Yahoo!!" and a yellow exclamation span. This is pure HTML/CSS — no image, no SVG. The alternative — using an image logo — would load faster from cache but would not be accessible to screen readers without alt text. The text-based approach means the logo scales with the user's font size preferences and is always readable. The purple background (#7b1fa2) and yellow accent (#ffeb3b) match Yahoo!'s original 1994-1996 color scheme.

Category grid: 4 columns vs variable. The 14 Yahoo! categories are laid out in a 4-column CSS Grid. Four columns provides enough density to see many categories without scrolling while keeping individual category names readable. The alternative — 3 columns — would be too sparse (requiring more vertical space), and 5 columns would make category names wrap awkwardly on standard 1024px screens. The gap: 0.5rem creates clear visual separation between groups without being wasteful.

Search section vs inline search. The search box has its own full-width banner section (.search-section) with a purple background and yellow border. This is the 1994 approach — search was the primary action, so it gets a dedicated visual zone. Modern design would place search in the header, but the dedicated search section is historically accurate and emphasizes the portal's role as a discovery tool. The yellow bottom border (3px solid #ffeb3b) creates a clean visual break between search and content.

Sidebar layout for email + widgets. The page uses a two-column grid (1fr 280px) in the .content-row — a main content area and a sidebar for the email login, trending links, and cool sites. This sidebar pattern was a Yahoo! innovation — it kept secondary content (login, promotions) out of the main reading area while keeping it accessible. The 280px sidebar width is narrow enough to leave sufficient space for content on a 900px container, and wide enough for form inputs and link text.

News feed as structured list. News items use a <ul> with <li> elements, each containing a link and a source attribution. Using a list is semantically correct — news items are a list of articles. The thin bottom border (1px solid #eee) separates items without the visual weight of card containers. Source tags use a muted color (#888) and smaller font to differentiate attribution from headlines without relying on visual separators.

Font choice: Georgia vs system sans-serif. The page uses font-family: Georgia, 'Times New Roman', Times, serif for body text — a serif font that was common on the web in the mid-1990s. Serif fonts were dominant before CSS made sans-serif layouts practical. The nav links, search box, and footer use Arial (system sans-serif) for secondary text — a pattern that was emerging in 1996 as designers began mixing serif headlines with sans-serif UI elements.

Browser compatibility

  • CSS Grid (grid-template-columns: repeat(4, 1fr)): Supported since Chrome 57, Firefox 52, Safari 10.3. A 1996 page would have used HTML tables for layout. CSS Grid is used here for cleaner code, but the visual result is identical to the table-based originals. For older browser support, this could be replaced with float-based columns or a table layout.
  • max-width with auto margins: Supported universally. The .container centering pattern (max-width: 900px; margin: 0 auto) works in all browsers including IE 5.5+.
  • text-shadow on logo: Supported since Chrome 4, Firefox 3.5, Safari 4. The shadow creates depth on the header text. In older browsers, the text renders without shadow — no breakage.
  • gap in CSS Grid: Supported since Chrome 66, Firefox 61, Safari 12.1. grid-gap (the older prefix) is supported slightly earlier (Chrome 57) but the unprefixed version is the modern standard.
  • ::placeholder on inputs: Supported since Chrome 4, Firefox 4, Safari 5. The placeholder color is browser-default and varies by OS — this is intentional (no custom styling needed for a historical clone).
  • max-width on the search box: The .search-box uses max-width: 500px; width: 100% — a responsive pattern that works in IE 7+. On very narrow screens, the search box scales down but may become too small for the button. The mobile breakpoint at 700px handles this.

Accessibility details

  • Heading hierarchy. The page uses a single <h1> (the "Yahoo!!" logo), a visually-hidden <h2> for the search section, an <h2> for "Category Directory" (styled as a heading), and <h3> for each category group and sidebar section. This creates a clear document outline: Page → Portal → Category Group → Links. The category grid header ("Category Directory") should ideally be an <h2> but is currently an inline heading — for production, add <h2 class="sr-only"> before the grid.
  • Search input label. The search input has no explicit <label>. Add <label for="search" class="sr-only">Search the web</label> for screen reader support. Without it, screen reader users hear "Search the web, edit text" only if the placeholder is preserved as the accessible name — and placeholder-as-label fails WCAG 2.5.3 when the placeholder disappears on input.
  • Category grid semantics. Each category group is a <div> with an <h3> and a <ul>. This is semantically correct — each group is a distinct category (heading) with a list of links. Screen readers navigate by heading level (h3) to jump between categories.
  • Login form labels. Each input in the login box has an associated <label for="...">. This is WCAG 3.3.2 compliance — the label is explicitly associated via the for attribute, meaning clicking the label focuses the input, and screen readers announce the label on focus.
  • Color contrast. The purple header (#7b1fa2) with white text is ~6.5:1 contrast. Category links in blue (#1565c0) on white are ~6.8:1. Both exceed WCAG AA 4.5:1. The yellow text (#ffeb3b) on purple background is ~3.2:1 — does not meet AA for normal text. Consider using a lighter yellow or darker purple for the exclamation mark.
  • Footer links. Footer links have no landmark. Wrap the footer link list in a <nav aria-label="Footer"> to distinguish it from the main navigation.

Common pitfalls

  • Category grid overflow on small screens. The 4-column grid collapses to 2 columns at 700px breakpoint. Without this breakpoint, categories become unreadably narrow on mobile. Test at 320px width — 2 columns may still be too tight; consider 1 column below 480px.
  • Search box and button stacking. The inline-flex search box wraps the button below the input on very narrow screens. Adding flex-wrap: wrap or min-width: 0 to the input prevents this.
  • Missing label on search input. The most common accessibility gap in search boxes. Without a label, screen readers rely on aria-label="Search" or title="Search" to identify the input. Placeholder text is not a substitute — it disappears when the user types.
  • Password input without autocomplete. The password field lacks autocomplete="current-password" — browser password managers won't offer to fill the field. Always add autocomplete attributes to login forms.
  • News source text too similar to link text. The .source span uses color: #888 — approximately 4.2:1 on white. This is borderline for WCAG AA. Consider #767676 for better readability.

Key concepts

  • grid-template-columns: repeat(4, 1fr) — 4 equal-width columns. 1fr splits available space equally
  • <ul> for link lists — Category groups and news feeds are unordered lists of links. Semantically correct, screen-reader friendly
  • max-width + margin: 0 auto — Centered container. Restricts content width while allowing background colors to bleed edge-to-edge
  • grid-template-columns: 1fr 280px — Two-column layout: main content fills remaining space, sidebar is fixed 280px
  • label for="input-id" — Explicit label association. Clicking label focuses the input. Required for WCAG 3.3.2
  • <h1> → <h2> → <h3> — Heading hierarchy. Screen readers navigate by heading level. Don't skip levels

Next up

Step 2 adds full styling with a refined purple theme, hover effects on category links, news feed visual hierarchy, and improved spacing.

Header & Search ▶ Run
<header>
  <h1>Yahoo!<span>!</span></h1>
  <p>Jerry's Guide to
    the World Wide Web</p>
</header>

<div class="search-section">
  <div class="search-box">
    <input type="text"
      placeholder="Search...">
    <button>Search</button>
  </div>
  <div class="nav-links">
    <a href="#">Home</a>
    <a href="#">New</a>
    <a href="#">Cool</a>
  </div>
</div>
Category grid CSS
.categories {
  display: grid;
  grid-template-columns:
    repeat(4, 1fr);
  gap: 0.5rem;
}
.cat-col h3 {
  font-size: 0.8rem;
  color: #7b1fa2;
  border-bottom:
    1px solid #eee;
  padding-bottom: 0.2rem;
}
.cat-col a {
  color: #1565c0;
  text-decoration: none;
}
.cat-col a:hover {
  text-decoration: underline;
}
Sidebar + login
.content-row {
  display: grid;
  grid-template-columns:
    1fr 280px;
  gap: 1.5rem;
}
.login-box {
  background: #f3e5f5;
  border: 1px solid #e1bee7;
  border-radius: 4px;
  padding: 1rem;
}
.login-box label {
  display: block;
  font-size: 0.75rem;
  color: #555;
}
.login-box input {
  width: 100%;
  padding: 0.35em 0.5em;
  border: 1px solid #ccc;
  border-radius: 2px;
  margin-bottom: 0.5rem;
}
📐 1994 layout mindset: Yahoo! originally used HTML tables for layout. We use CSS Grid here for cleaner code, but the visual structure is identical: a full-width header, a search band, a category grid, and a two-column content+sidebar section. The serif fonts (Georgia), purple header (#7b1fa2), and yellow accent (#ffeb3b) are historically accurate to Yahoo!'s 1994-1996 brand.
02

Styling & Layout

Complete

Why learn this?

Refining a portal's visual design teaches the CSS patterns that turn a functional layout into a cohesive brand experience. Yahoo's original purple-and-yellow color scheme, gradient headers, card-based category groups, and hover states defined the late-90s web aesthetic. This step covers CSS gradients (linear-gradient for the header and search section), card styling with borders and shadows, link hover effects with transition animations, pseudo-element decorations (::before icons on headings), box-shadow depth effects, and a refined mobile breakpoint strategy. These patterns are directly applicable to any modern dashboard, news site, or content portal.

Design decisions & tradeoffs

Gradient header vs flat color. The header uses linear-gradient(135deg, #7b1fa2, #9c27b0) — a diagonal gradient from purple to slightly lighter purple. This adds visual depth without distracting from the content. The alternative — a flat purple background — would be simpler but less polished. The gradient combined with box-shadow: 0 3px 12px rgba(0,0,0,0.15) creates a subtle floating effect that separates the header from the search section below. The 4px gradient bar (::after with linear-gradient(90deg, #ffeb3b, #ff9800, #ffeb3b)) provides a decorative transition between header and search — a pattern Yahoo! itself used extensively.

Card-style category groups vs plain lists. Each category group is now a white card (background: #fff; border-radius: 6px; padding: 0.75rem; border: 1px solid #e8e0ed) with a subtle hover effect (box-shadow: 0 3px 12px rgba(123,31,162,0.08); border-color: #ce93d8). The alternative — plain text lists without containers — would be lighter but wouldn't visually group the links. The card approach creates clear visual hierarchy: the page has a category header, then individual group cards, each with a heading and links. The hover effect provides interactive feedback without being aggressive.

Pseudo-element decorations on headings. Category headings use ::before pseudo-elements for decorative icons: on the section header, on news, on mail, on sidebar sections. These are CSS-only — no extra HTML elements needed. The alternative — embedding icons in the HTML as <span> tags — would give more control over sizing but adds markup that doesn't belong to the content structure. Pseudo-element icons keep the HTML clean and semantic while adding visual flair.

Search focus state with yellow glow. The search input uses a custom focus ring: border-color: #ffeb3b; box-shadow: 0 0 0 3px rgba(255,235,59,0.25). This creates a visible yellow glow that matches the brand accent. The alternative — browser-default blue outline — would be less brand-coherent. The rgba(255,235,59,0.25) ensures the glow is visible but not overwhelming. The 3px spread creates a clear focus indicator that meets WCAG 2.4.7 (focus visible) requirements.

News feed as cards with source badges. The news section is now a white card with rounded corners and a border, matching the category card style. Source labels are right-aligned with white-space: nowrap and uppercase styling — they sit beside each headline without breaking the line. The thin #f3e5f5 borders between items create separation that's visible but doesn't compete with the headlines. The alternative — using a table or grid for news items — would be more complex without providing better readability.

Link hover effects with padding shift. Category links use a subtle padding-left: 3px on hover combined with a color change. This creates a slight "indent" effect that signals interactivity visually and through motion. The ::before dot (·) on each link provides a visual bullet that changes color on hover — reinforcing the interactive state. The transition is set to 0.15s — fast enough to feel responsive, slow enough to be noticeable.

Browser compatibility

  • linear-gradient(): Supported since Chrome 26, Firefox 16, Safari 6.1, IE 10. The header and search gradients work in all modern browsers. IE 9 and below show a flat fallback color.
  • box-shadow: Supported since Chrome 4, Firefox 3.5, Safari 5, IE 9. Used on the header, category cards, buttons, and login box. Falls back silently in IE 8 — no shadow, no breakage.
  • ::before and ::after pseudo-elements: Supported since Chrome 4, Firefox 3.5, Safari 4, IE 9. The decorative icons on headings and link bullets rely on pseudo-elements. In IE 8, these decorations are absent but the content remains readable.
  • border-radius: Supported since Chrome 4, Firefox 4, Safari 5, IE 9. Used on category cards, news section, login box, sidebar sections, buttons, and nav links. Creates the rounded card aesthetic. Falls back to square corners in IE 8-.
  • transition: Supported since Chrome 26, Firefox 16, Safari 6.1, IE 10. Used on hover effects (links, buttons, cards). In older browsers, hover changes happen instantly without animation — functional but less polished.
  • object-fit: Not used in this step — all decorations are CSS-only. No browser compatibility concerns for this aspect.

Accessibility details

  • Pseudo-element decoration semantics. The ::before icons (▸, ◆, ✉, ·) are decorative — they have no aria-hidden="true" attribute. Screen readers may announce these characters as "black right-pointing triangle" or similar. For production, add aria-hidden="true" to pseudo-elements that are purely decorative by using a CSS class approach or adjusting the content property to include "" with a separate background image.
  • Hover-only interactive states. Category link hover effects (padding-left shift, color change) rely on :hover. Keyboard users tabbing through links won't see these effects. Add .cat-col a:focus-visible { padding-left: 3px; color: #7b1fa2; } to match hover behavior for keyboard focus.
  • Focus indicator on search button. The search button has a :hover effect but no :focus-visible style. Keyboard users tabbing to the Search button see the browser-default outline (typically blue), which may not match the brand theme. Add a consistent focus ring matching the search input: .search-box button:focus-visible { outline: 2px solid #ffeb3b; outline-offset: 2px; }.
  • Card hover as single signal. Category cards use hover for both aesthetic polish (shadow, border-color) AND interaction intent (linking to the category page). Users who cannot hover (touch, keyboard) miss this signal. The card itself isn't a link — only the links inside it are. Consider making entire cards clickable for better touch targets.
  • Color contrast in hover states. Category link hover changes from #5a2d82 to #7b1fa2 — both on white background. The contrast ratio difference is minimal (~5.8:1 vs 6.5:1). For users with color vision deficiency, the hover is primarily communicated by the padding-left shift (motion signal) rather than color change alone.
  • Gradient background contrast. The header gradient (#7b1fa2 to #9c27b0) has minimal internal contrast variation (~1.2:1 between the two colors). The white text on this gradient is ~6.5:1 — exceeding AA for normal text.

Common pitfalls

  • Gradient banding on large screens. The header gradient may show visible color bands on high-resolution displays or when the header is very wide. Add a subtle background-size: 200% 100% or use a very subtle noise texture to break up banding. The current gradient uses only 2 stops — adding a 3rd stop at 50% reduces banding significantly.
  • Pseudo-element content read by screen readers. ::before { content: '▸'; } is announced by screen readers. Unless the character is semantic, set content: "" and use a background image, or add aria-hidden="true" to the parent element. For the dot separator on links, a CSS background approach is cleaner than a pseudo-element.
  • News items right-align source on narrow screens. The display: flex; justify-content: space-between on news items pushes the source label to the right. On narrow screens (<400px), the source may wrap below the headline. Add flex-wrap: wrap or set a minimum width on the source span.
  • Card hover effect clipping. The category card hover uses box-shadow — this doesn't clip. But if a transform: translateY(-2px) effect were added (common for card hovers), it would be clipped by the parent container's overflow. If adding lift effects later, ensure no overflow: hidden on parent elements.
  • Button gradient doesn't match theme on hover. The login button uses linear-gradient(180deg, #7b1fa2, #6a1b9a). The hover state adds a shadow but doesn't change the gradient. A darker hover gradient (#6a1b9a → #5a1590) would give stronger interactive feedback.

Key concepts

  • linear-gradient(135deg, #7b1fa2, #9c27b0) — Diagonal gradient. Direction via angle, not side keywords
  • ::before { content: '▸'; } — Pseudo-element decoration. Adds visual icons without extra HTML
  • box-shadow: 0 3px 12px rgba(0,0,0,0.15) — Depth shadow. Values: x-offset y-offset blur-radius color-opacity
  • transition: color 0.15s, padding-left 0.15s — Animated property changes. Short duration = snappy feedback
  • white-space: nowrap — Prevent text wrapping. Used on source badges to keep them on one line
  • border-radius: 6px — Rounded card corners. Works with border and background colors
  • flex-wrap: wrap — Allow items to wrap on narrow screens. Used on nav links to prevent overflow

Next up

Step 3 adds JavaScript interactivity: search filtering, category browse with highlighting, and a news feed with dynamic content.

Gradient header ▶ Run
header {
  background: linear-gradient(
    135deg, #7b1fa2, #9c27b0
  );
  box-shadow:
    0 3px 12px rgba(0,0,0,0.15);
}
header::after {
  content: '';
  position: absolute;
  bottom: 0; left: 0; right: 0;
  height: 4px;
  background: linear-gradient(
    90deg, #ffeb3b, #ff9800,
    #ffeb3b
  );
}
Category cards CSS
.cat-col {
  background: #fff;
  border: 1px solid #e8e0ed;
  border-radius: 6px;
  padding: 0.75rem;
  transition:
    box-shadow 0.2s,
    border-color 0.2s;
}
.cat-col:hover {
  box-shadow:
    0 3px 12px
    rgba(123,31,162,0.08);
  border-color: #ce93d8;
}
.cat-col a {
  transition:
    color 0.15s,
    padding-left 0.15s;
}
.cat-col a:hover {
  color: #7b1fa2;
  padding-left: 3px;
}
.cat-col a::before {
  content: '·';
  color: #ce93d8;
  margin-right: 0.3rem;
}
News + sidebar cards
.news-section {
  background: #fff;
  border: 1px solid #e8e0ed;
  border-radius: 8px;
  padding: 1.25rem;
}
.news-list li {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  padding: 0.6rem 0;
  border-bottom:
    1px solid #f3e5f5;
}
.news-list .source {
  color: #999;
  font-size: 0.65rem;
  white-space: nowrap;
  text-transform: uppercase;
}
🎨 Gradient tip: The linear-gradient(135deg, #7b1fa2, #9c27b0) creates a diagonal that mimics light hitting from the top-left. For the header decorative bar, linear-gradient(90deg, #ffeb3b, #ff9800, #ffeb3b) creates a warm gold underline that visually ties the purple header to the search section below. Use rgba() in box-shadows instead of #000 opacity to keep shadows monochrome-true — rgba(123,31,162,0.08) casts a purple-tinted shadow that matches the brand.
03

Search & Filter

Complete

Why learn this?

Client-side search filtering is one of the most common JavaScript patterns on the web — used in navigation menus, product catalogs, documentation indexes, and file explorers. The core mechanic (an input field that filters visible elements in real time) teaches event-driven programming with input events, DOM traversal with closest() and querySelectorAll(), and state management (tracking both text and letter filters simultaneously). The alphabet filter bar adds a secondary dimension to the filtering — a pattern used by contact lists, dictionary indexes, and category browsers across the web.

Design decisions & tradeoffs

data-category attributes vs class-based filtering. Each category card stores its machine-readable name in data-category="arts". The filter function checks whether the card's heading, links, or data attribute contain the search term. This approach is more robust than class-based filtering because it doesn't require maintaining CSS classes for every filter category — the data-* attribute is the single source of truth. The alternative — filtering by visible text content only — would work without data attributes but would fail if the search input doesn't match the exact visible text.

Dual filter: text search + letter filter. The search supports two simultaneous filter dimensions: a free-text search input and an alphabet letter bar. Both filters are AND-combined — categories must match both the text query AND the selected letter. This allows users to browse by first letter (e.g., "S" → Science, Society) while also narrowing further by text. The alternative — a single text input without letter filtering — is simpler but less useful for browsers who don't know exactly what they're looking for.

display: none for hiding vs class toggle. Non-matching categories use a .hidden class with display: none. This is the standard pattern — display: none removes elements from both layout and accessibility tree, which is correct for filtering (you don't want screen readers announcing hidden items). The alternative — visibility: hidden — would preserve layout space but leave empty holes in the grid. The CSS transition on opacity and transform on the .cat-col base provides a subtle entrance animation when items reappear.

Highlighted state for letter filter. When a letter is selected, matching categories get a yellow highlight (border-color: #ffeb3b; box-shadow: 0 0 0 3px rgba(255,235,59,0.15)) — the same brand accent as the header. This provides clear visual feedback about which categories match. The alternative — simply showing/hiding without highlighting — would be less informative (the user sees fewer categories but doesn't know why). The highlight reinforces the connection between the letter button click and the visible result.

Inline news expand with dynamic content generation. News items have an expand button (▸/▾) that reveals a detail paragraph. The detail content is generated dynamically the first time the item is expanded (createElement('div') appended to the <li>) — not pre-rendered in the HTML. This keeps the initial HTML smaller and defers content generation to interaction time. The alternative — pre-rendering all details in hidden <div> elements — would avoid JS DOM creation but add weight to the initial page load. For 3-6 news items, the dynamic approach is simpler and keeps the HTML clean.

Clear button vs automatic reset. The search section has a dedicated "Clear" button that resets both the text input and the letter filter. The alternative — clearing on Escape key or when the input is empty — would be more discoverable for keyboard users. The Clear button is more explicit and follows Yahoo!'s original pattern of providing clear action buttons for every interactive state.

Browser compatibility

  • HTMLElement.dataset (col.dataset.category): Supported since Chrome 7, Firefox 6, Safari 5.1. Reads data-category values as camelCase properties. Falls back to getAttribute('data-category') in IE 10-.
  • Element.closest(): Supported since Chrome 41, Firefox 35, Safari 6. The toggleNews() function uses el.closest('li') to find the parent news item from the click target. For IE support, use a while-loop parent traversal instead.
  • String.prototype.includes(): Supported since Chrome 41, Firefox 40, Safari 9. Used for text matching. Falls back to indexOf() !== -1 in IE 11.
  • String.prototype.startsWith(): Supported since Chrome 41, Firefox 17, Safari 9. Used for letter filtering (name.startsWith(letterFilter)). Falls back to indexOf(letterFilter) === 0 in older browsers.
  • Array.from(): Supported since Chrome 45, Firefox 32, Safari 9. Used in toggleAllNews() to convert NodeList to Array for .every(). For broader support, use a for loop instead.
  • classList.toggle(): Supported since Chrome 8, Firefox 3.6, Safari 5.1. The classList.toggle('visible') pattern works in all modern browsers. IE 10+ supports it.

Accessibility details

  • Search results not announced to screen readers. When the search input filters categories, the #searchStats element updates with "N of 12 categories available" but has no aria-live="polite" attribute — screen readers won't announce the count change. Add aria-live="polite" to the stats element so filter results are announced automatically.
  • Hidden categories still in DOM. The .hidden class uses display: none, which removes elements from the accessibility tree — correct behavior. Non-matching categories are invisible to screen readers and removed from tab order. This is the accessible way to implement filters.
  • Letter bar button semantics. The letter bar uses <span> elements with click handlers. These are not keyboard accessible by default — they need role="button", tabindex="0", and keyboard event handlers for Enter/Space. For production, use <button> elements instead, which provide keyboard interaction and screen reader announcements for free.
  • News expand button semantics. The expand buttons (/) are <span> elements with onclick. Same issue — use <button> for proper keyboard accessibility. The aria-expanded attribute should toggle between "true" and "false" to communicate the expand state to screen readers.
  • Focus management after filter. After typing in the search box, focus stays on the input — correct behavior. After clicking a letter filter, focus stays on the letter button. If all categories are hidden (no results), focus should move to the "No categories match" message or the search input for correction.
  • No results state accessibility. The .no-results message appears when no categories match. It should have role="status" to be announced by screen readers. Currently it's a plain <div> with display: block — visually visible but not announced.

Common pitfalls

  • Case sensitivity in search. The search converts both input and content to lowercase via .toLowerCase(). Without this, searching "arts" wouldn't match "Arts" (capitalized). Always normalize case for text search comparisons.
  • Letter filter initial state mismatch. The letter filter is initialized with "All" active. But the active class is set on the span, and the letter filter variable is empty. If the letter bar uses data-letter="all", the filter function must check for 'all' and treat it as "no filter" — otherwise categories with "all" in their name would match instead of showing all categories.
  • DOM content duplication on multiple clicks. toggleNews() creates the detail div on first expand. If called again before creation completes (double-click), two identical detail divs could be appended. Guard with if (detail) return or use a flag.
  • Expand/collapse state tracking for "Expand all". The "Expand all" button checks all expand buttons to decide its label. If a user manually collapses one item via its individual button, "Expand all" still shows "Collapse all" (if at least one is open). The toggle logic should track individual states rather than assuming all items share the same state.
  • No debounce on search input. The search filters on every input event — which fires on every keystroke. For 12 categories with 4 links each, the performance is imperceptible. But for a larger directory (50+ categories), add a debounce of 150-200ms to avoid redundant filtering while the user is still typing.

Key concepts

  • input event — Fires on every keystroke. Use instead of change (which only fires on blur) for real-time filtering
  • dataset.category — Reads data-category attribute. Declarative metadata storage in HTML
  • classList.toggle('hidden', condition) — Conditional class toggle. Second argument sets boolean state directly
  • el.closest('li') — Walk up the DOM tree to find the nearest matching ancestor. Used for news expand target
  • element.startsWith() — Check if string starts with a prefix. Used for letter filtering
  • document.createElement() + .appendChild() — Dynamic DOM creation. Defer content generation to interaction time
  • aria-live="polite" — Announce dynamic content changes to screen readers. Essential for filter result counters

Next up

Step 4 adds email login with form validation, a responsive hamburger nav, and final visual polish.

Search filter JS ▶ Run
const updateCategories =
  (filterText, letterFilter) => {
  let visible = 0;
  catCols.forEach(col => {
    const name = col.dataset.category;
    const heading = col
      .querySelector('h3')
      .textContent.toLowerCase();
    const links = Array.from(
      col.querySelectorAll('a'))
      .map(a => a.textContent
        .toLowerCase()).join(' ');

    const textMatch = !filterText ||
      name.includes(filterText) ||
      heading.includes(filterText) ||
      links.includes(filterText);
    const letterMatch = !letterFilter ||
      name.startsWith(letterFilter) ||
      heading.startsWith(letterFilter);

    if (textMatch && letterMatch) {
      col.classList
        .remove('hidden', 'highlighted');
      if (letterFilter)
        col.classList.add('highlighted');
      visible++;
    } else {
      col.classList.add('hidden');
      col.classList.remove('highlighted');
    }
  });
  noResults.classList
    .toggle('visible', visible === 0);
};
Letter filter bar
<div class="letter-bar">
  <span data-letter="all"
    class="active">All</span>
  <span data-letter="A">A</span>
  <span data-letter="B">B</span>
  <!-- ... -->
</div>

letterBtns.forEach(btn => {
  btn.addEventListener('click',
  () => {
    letterBtns.forEach(
      b => b.classList
        .remove('active'));
    btn.classList.add('active');
    const letter =
      btn.dataset.letter === 'all'
      ? '' : btn.dataset.letter
        .toLowerCase();
    updateCategories(query, letter);
  });
});
News expand/collapse
const toggleNews = (el) => {
  const li = el.closest('li');
  let detail = li
    .querySelector('.news-detail');
  if (!detail) {
    detail = document
      .createElement('div');
    detail.className = 'news-detail';
    detail.textContent =
      headlines[title];
    li.appendChild(detail);
  }
  detail.classList.toggle('visible');
  el.textContent = detail
    .classList.contains('visible')
    ? '▾' : '▸';
};
🔍 Dual filter approach: The search combines free-text (includes()) with alphabet filtering (startsWith()) using AND logic. The letter bar uses data-letter attributes mapped to the first letter of each category name. The highlight effect on letter-filtered categories (border-color: #ffeb3b) provides visual feedback that helps users understand why specific categories appeared. This pattern scales to any number of categories — the filter function only checks three sources: data attribute, heading text, and link text.
04

Email Login & Responsive

Complete

Why learn this?

This final step combines two essential frontend skills: form validation (the gatekeeper between users and your backend) and responsive navigation (the pattern that makes sites usable on any device). Client-side form validation with real-time feedback is a requirement for any login, sign-up, or checkout form. The hamburger menu — controversial but ubiquitous — is the standard mobile navigation pattern used by every major site. Together, these two features transform a desktop portal into a mobile-friendly web app, teaching form state management, CSS media queries for navigation hiding/showing, and the intersection of responsive design with interactive elements.

Design decisions & tradeoffs

Hamburger menu vs horizontal scroll on mobile. The nav links collapse into a hamburger button (☰) below 768px. The links slide down with a CSS transition on max-height and opacity. The alternative — keeping links visible and letting them wrap — works but takes up significant vertical space on mobile. The hamburger pattern saves space but adds a tap to access navigation. The transition uses max-height: 0 → 300px rather than height: auto because CSS can't animate height: automax-height is the standard workaround (even though 300px is an arbitrary limit).

CSS max-height animation vs display toggle. The nav uses max-height + opacity transition for smooth open/close. The alternative — toggling display: none/block — is instant (no animation) and simpler but provides no visual feedback. The max-height approach creates a smooth slide-down effect. The pitfall: max-height must be large enough to fit all links (300px here), and if the animation completes before the content is fully revealed, it appears chopped. The desktop breakpoint (min-width: 769px) overrides with max-height: none to disable the collapsing entirely on large screens.

Form validation: blur vs input vs submit. Validation fires on blur (when the user leaves a field) and re-validates on input (when they start typing after the first error). On submit, all fields validate simultaneously. This is the standard "dirty" pattern — validate on first interaction, then validate in real time. The alternative — validating only on submit — is simpler but shows all errors at once (overwhelming). The alternative — validating on every keystroke from the start — is aggressive and annoys users before they've finished typing.

Form states: error vs valid vs default. Each input has three visual states: .error (red border), .valid (green border), and default (purple border on focus). The error state includes a visible error message below the field — dual-signal feedback (color + text) that meets WCAG 1.4.1. The valid state uses a subtle green border without an explicit success message — quiet enough to not distract but visible enough to signal progress. The novalidate attribute disables the browser's native validation popups, giving full control over the error UX.

Responsive search box on very small screens. At 480px and below, the search box switches to a vertical layout (flex-direction: column) with the button taking full width. This prevents the button from being clipped on very narrow viewports. The alternative — keeping the inline layout with flex-wrap: wrap — would wrap the button below the input anyway (the default behavior for overflowing flex items), but the column layout is more intentional and visually cleaner.

Hamburger icon uses HTML entity vs SVG. The hamburger uses the ☰ HTML entity (&#9776;) and switches to ✕ (&#10005;) when open. The alternative — SVG icons — would be sharper on high-DPI screens but require more markup. HTML entities are zero-dependency and render in every browser. The aria-label attribute on the button ("Toggle navigation") provides the accessible name regardless of the visible icon character.

Browser compatibility

  • max-height with transition: Supported universally. The nav slide-down animation works in all browsers, though max-height can't perfectly animate to auto — the 300px value must be generous enough to contain all links.
  • media (max-width: 768px): Supported universally. The responsive breakpoints for hamburger visibility, grid columns, and search box layout work in all browsers.
  • novalidate form attribute: Supported universally. Prevents native HTML5 validation popups across all browsers.
  • pattern attribute vs JS regex: The form uses JS validation (/^[^\s@]+@[^\s@]+\.[^\s@]+$/) instead of the HTML5 pattern attribute. JS gives more control over error messages and timing. The pattern attribute could be used as a fallback for environments where JS is disabled.
  • position: absolute for hamburger centering: The hamburger is centered vertically with top: 50%; transform: translateY(-50%). This works in all browsers. In older browsers without transform support (IE 8-), the button appears at the top of the header — functional but misaligned.

Accessibility details

  • Hamburger button semantics. The hamburger is a native <button> element — keyboard accessible (Enter/Space to activate) and announced by screen readers. The aria-label="Toggle navigation" provides context that the ☰ symbol alone doesn't convey. The button text changes between ☰ and ✕, which is announced by screen readers as "symbols" — the aria-label stays consistent regardless of the icon.
  • Nav visibility for screen readers. The mobile nav uses max-height: 0; overflow: hidden to hide links on small screens. When hidden, links are not announced by screen readers — correct behavior. When open, links are focusable and announced. The desktop breakpoint uses max-height: none !important to ensure links are always visible on large screens regardless of JS state.
  • Error message association. Error messages are <div> elements placed immediately after each input but not linked via aria-describedby. For production, add aria-describedby="emailError" to the email input so screen readers announce the error when it appears. Adding role="alert" to the error messages would make them announced immediately on change.
  • Form success announcement. When login succeeds, the form is replaced with a success message. Without focus management, keyboard focus remains on the (now hidden) submit button. Move focus to the success heading with .focus() and add tabindex="-1" to the heading so it can receive focus.
  • Color contrast in validation states. Error border (#d32f2f) on white is ~4.3:1 — passes AA for non-text elements (3:1) but not for text. Error text (#d32f2f on white) is ~4.3:1 — passes AA for large text (3:1) but not for small text (4.5:1). Consider #c62828 for better contrast. Valid border (#388e3c) on white is ~3.6:1 — passes AA for non-text elements but not for text.
  • Touch targets on mobile. The hamburger button is 38×38px — below the recommended 44×44px minimum touch target (WCAG 2.5.8). Increase to 44px for better mobile usability. Category links and letter bar spans are similarly small — consider larger tap targets for touch devices.

Common pitfalls

  • max-height transition timing mismatch. If max-height is set to 300px but the content is only 100px, the transition completes in ~1/3 of the transition-duration — the animation appears to finish early. The visual effect is a fast slide-down followed by a pause. Use a value close to the actual content height, or use a JS-driven animation for pixel-perfect timing.
  • Desktop override with !important. The @media (min-width: 769px) { .nav-links { max-height: none !important; } } uses !important to override the inline or mobile styles. Without !important, the mobile styles might persist on desktop if the hamburger was toggled before resizing. An alternative: remove the open class on window resize with a JS resize listener.
  • Form validation on submit doesn't scroll to errors. When the user clicks Sign In and validation fails, the error messages appear but the page doesn't scroll to the first error. Add document.querySelector('.error').focus() or .scrollIntoView() to bring the user to the first invalid field.
  • Hamburger state persistence on resize. If a user opens the hamburger menu on mobile, then rotates their phone to landscape (which may cross the 768px breakpoint), the menu might stay open with the ✕ icon, but the desktop styles may show all links already. Add a resize listener that resets the hamburger state when crossing the breakpoint.
  • Password autocomplete not set. The password field lacks autocomplete="current-password" — browser password managers won't offer to fill credentials. Always add autocomplete attributes to login forms for better UX.

Key concepts

  • max-height: 0 → 300px; transition: max-height 0.3s — Slide-down nav animation. CSS-only, no JS height calculation needed
  • @media (max-width: 768px) — Mobile breakpoint. Hides nav, shows hamburger, collapses grid columns
  • novalidate — Disable browser native validation. Full control over error UX with JS
  • blur + input events — Two-phase validation. Validate on field exit, re-validate on correction
  • classList.toggle('open') — Toggle nav visibility. CSS classes, not inline styles
  • aria-label="Toggle navigation" — Accessible button label. Screen readers announce this instead of the icon character
  • e.preventDefault() — Intercept form submission. Prevents page reload, enables JS validation flow
Hamburger nav ▶ Run
<button class="hamburger"
  id="hamburgerBtn"
  aria-label="Toggle nav">
  &#9776;
</button>

<div class="nav-links"
  id="navLinks">
  <a href="#">Home</a>
  <a href="#">New</a>
  <a href="#">Cool</a>
</div>

<script>
hamburger.addEventListener(
  'click', () => {
  navLinks.classList.toggle('open');
  btn.textContent = navLinks
    .classList.contains('open')
    ? '&#10005;' : '&#9776;';
});
</script>
Responsive CSS
@media (max-width: 768px) {
  .hamburger { display: flex; }
  .nav-links {
    max-height: 0;
    opacity: 0;
    overflow: hidden;
    flex-direction: column;
    transition: max-height 0.3s,
      opacity 0.25s;
  }
  .nav-links.open {
    max-height: 300px;
    opacity: 1;
  }
  .categories {
    grid-template-columns:
      repeat(2, 1fr);
  }
  .content-row {
    grid-template-columns: 1fr;
  }
}
@media (min-width: 769px) {
  .nav-links {
    max-height: none !important;
    opacity: 1 !important;
  }
}
Form validation JS
const validateEmail = () => {
  const valid = /^[^\s@]+@[^\s@]+\.
    [^\s@]+$/.test(
    emailInput.value.trim());
  emailInput.classList
    .remove('error', 'valid');
  emailError.classList
    .remove('visible');
  if (!valid) {
    emailInput.classList.add('error');
    emailError.textContent =
      'Please enter a valid email.';
    emailError.classList.add('visible');
  } else {
    emailInput.classList.add('valid');
  }
  return valid;
};

emailInput.addEventListener(
  'blur', validateEmail);
emailInput.addEventListener(
  'input', () => {
  if (emailInput.classList
    .contains('error'))
    validateEmail();
});

form.addEventListener(
  'submit', (e) => {
  e.preventDefault();
  if (validateEmail() &&
    validatePass()) {
    form.reset();
    form.style.display = 'none';
    loginSuccess
      .classList.add('visible');
  }
});
📱 Mobile-first vs desktop-first: The responsive approach here is desktop-first — styles are written for the 960px layout, and max-width breakpoints override for smaller screens. This is historically accurate for a 1996 portal that didn't consider mobile. For a modern project, use mobile-first min-width breakpoints. The hamburger uses display: none on desktop (via default) and display: flex on mobile (via media query) — the opposite of the mobile-first pattern where hamburger is default and gets hidden on desktop.
Model
DeepSeek V4 Flash
Total Tokens
~20.7K
Est. Cost
~$0.02
Steps
4 / 4
Output tokens measured from demo files × 0.28 tok/byte. Input estimated from session context. DeepSeek V4 Flash pricing. Updated as each step completes.

✅ Yahoo! Clone Complete!

All 4 steps are built. The Yahoo! Clone demonstrates: category directory grid, search box with nav links, news feed with expandable items, sidebar with email login and trending links, gradient purple theme with yellow accents, real-time category filtering with letter bar, form validation with real-time feedback, responsive hamburger nav, and mobile breakpoints. Next project: a Craigslist Clone — teaching text-first layout, category listings, search/filter, and post detail pages.

06
Craigslist CloneClassifieds · Text-first layout · Category listings · Post detail

Lessons Learned — Build Process

The AI challenges, design insights, and pipeline improvements from building Yahoo! Clone with AI.

🎮
Why Yahoo! Clone Was Popular

Yahoo was the first web portal because there was no other way to find things. In 1994, when Yahoo launched as 'Jerry's Guide to the World Wide Web', there were only a few thousand websites — but no search engine could find them. Yahoo's category directory solved this by organizing the web hierarchically: Computers, Entertainment, News, Sports. Each category had subcategories, and each subcategory listed links. This hierarchical navigation pattern predates Google by four years and is still used by every marketplace, directory, and content site today. The transferable principle: when data is sparse, categorization beats search. Yahoo worked because humans could browse; Google worked later because there was too much to browse.

⚠️
The Problem — AI Shortcomings

The AI generated inconsistent link styling — the category directory had underlines on some links, none on others. The news feed section used different font sizes for headlines without a clear hierarchy. The layout was too wide for 1024px screens because the AI designed for a fixed 1200px viewport. The email login mock had placeholder text that looked too much like actual input — users would try to type and be confused.

🛠️
The Fix — Pipeline Improvements

Link styling is now standardized: a { text-decoration: none; } with a:hover { text-decoration: underline; } applied globally, then overridden per-section only when intentional. Viewport testing now includes 1024px as a mandatory breakpoint (Yahoo's original audience used 800x600 monitors — 1024px is generous). Placeholder text is clearly marked with placeholder="username (demo)" and lower opacity to distinguish from real input.

Each app builds on the last. The bugs found in yahoo-clone were fixed before the next app was built — and every bug saves time on every future app.