1995 Online Dating Pioneer — Profile creation, browse/search, swipe/like, matching algorithm, chat/messaging. Match.com launched in 1995 as one of the first online dating platforms and grew into the world's largest relationship-focused dating site. It pioneered personality-driven matching, detailed profile systems, and the browse-then-connect model that every dating app from Tinder to Bumble inherited. Inspired by Match.com circa 2000 and the evolution of online dating from desktop web to mobile-first swipe interfaces.
HTML Structure
CompleteWhy learn this?
Every dating platform — from Match.com to Tinder to Hinge — shares the same fundamental HTML patterns: a profile-centric header with search and notifications, a browse grid or stack of potential matches, detailed profile cards with photos and bio, and a messaging interface for conversations. Match.com's original desktop design is particularly instructive because it blends profile browsing (with compatibility scores, shared interests, and last active timestamps) alongside a structured messaging system — all centered around the user's own profile as the anchor point. Building a Match.com clone teaches the profile card pattern, the browse-then-connect UX flow, compatibility scoring display, and the messaging interface that powers every dating app on the web.
Design decisions & tradeoffs
Profile-first header with notifications. The Match.com header centers the user's profile photo and name as the primary element, with search and notification icons as secondary actions. This reflects Match's philosophy that your identity is the foundation of the experience — unlike eBay (where search dominates) or Amazon (where cart dominates), dating apps put your profile front and center. The red header (#e0134a) with white text matches Match.com's signature brand color, instantly recognizable across the dating industry.
Browse grid vs card stack. Match.com originally used a grid of profile cards (similar to eBay's item grid), not the swipeable card stack that Tinder popularized. The grid shows 6-12 profiles at once with photos, names, ages, distances, and compatibility scores. This design lets users compare multiple profiles simultaneously — better for deliberate, personality-focused dating than the rapid-fire swipe model. The alternative — a full-screen card stack — would favor quantity over quality and lose the comparison context that makes Match.com's browsing feel intentional.
Profile card structure: photo, info, compatibility. Each profile card is an <article> element with three sections: a primary photo (<figure> <img> </figure>), profile details (name, age, location, last active), and compatibility indicators (shared interests as tags, match percentage). This structured card pattern — photo dominant, details below, compatibility badges — is the universal dating card format used by every platform from Match to OkCupid to Bumble. The <article> element provides semantic meaning (each card is a self-contained profile) and screen reader navigation via landmarks.
Compatibility score as structured data. Match percentage is wrapped in a <span class=\"match-pct\"> with a data-score attribute for JavaScript consumption. The visible text shows a percentage (e.g., "87% Match") while the data-score attribute stores the raw number for filtering and sorting. The alternative — storing the score only as visible text — would make it impossible to sort or filter profiles by compatibility without parsing natural language. The data-score attribute is the hook for the JavaScript matching algorithm.
Interest tags as semantic list. Shared interests are displayed as <ul class=\"interest-tags\"> with <li> items — not as comma-separated text. This provides proper semantics (a list of interests), enables styling each tag independently (colored badges), and allows screen readers to announce each interest separately. The alternative — a text string like "hiking, coffee, travel" — loses the tag-level interaction (clicking a tag to find more people with that interest) and the visual rhythm that makes profile cards scannable.
Chat as a separate route. Messaging is at /chat/ rather than embedded in the browse page. This separation follows Match.com's original architecture — browsing and messaging are distinct mental modes. The browse page is about discovery (evaluating potential matches), while the chat page is about connection (deepening existing matches). Combining them would create cognitive overload and make both experiences worse. The <aside> element wraps the recent matches sidebar, providing a semantic landmark for "related but secondary content."
Browser compatibility
- CSS Grid (
grid-template-columns: repeat(auto-fill, minmax(280px, 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 profile photos with optional captions. IE 9+ supports both elements.<article>element: Supported since Chrome 4, Firefox 3.5, Safari 4. Semantic landmark for self-contained profiles. Older browsers treat it as a generic<div>— no breakage.object-fit: coveron profile photos: Supported since Chrome 31, Firefox 36, Safari 7.1. Used to crop profile photos to uniform aspect ratio. In older browsers, images stretch to fill the container.max-widthwith auto margins: Supported universally. The.containercentering pattern works in all browsers including IE 5.5+.<output>element for compatibility score: Supported since Chrome 10, Firefox 22, Safari 5.1. Used to display the calculated match percentage. IE does not support it — falls back to inline text.
Accessibility details
- Heading hierarchy. The page uses a single
<h1>(the "Match" brand) inside the header,<h2>for section titles ("Browse Singles"), and<h3>for each profile card's name. This creates a clean outline: Platform → Section → Individual Profile. The profile card titles are<h3>links — screen reader users navigate by heading to jump between profiles. - Profile card
<article>landmarks. Each profile is an<article>witharia-labelledbypointing to the profile name. Screen readers can navigate between articles using landmark navigation (Rotor on iOS, landmarks menu on desktop). - Image alt text. Profile photos have descriptive
alttext generated from the profile name and context (e.g., "Photo of Sarah, 28, from Portland"). This is critical for screen reader users who rely on alt text to understand the profile. Avoid generic alt text like "Profile photo." - Compatibility score accessibility. The match percentage uses
role="meter"witharia-valuenow,aria-valuemin="0", andaria-valuemax="100". This announces the score as a progress meter to screen readers — "87% match" becomes "meter, 87 percent." - Interest tag list semantics. Interest tags are an unordered list (
<ul>) — screen readers announce "list of 5 items" and let users navigate between tags. The visual presentation (colored badges) is purely CSS — the underlying HTML is a semantic list. - Color contrast. The Match red header (
#e0134a) with white text is ~5.8:1 contrast — exceeds WCAG AA. Profile names in dark text (#1a1a2e) on white cards is ~15:1 — exceeds AAA. Match percentage in red on white is ~5.8:1 — passes AA for normal text. Interest tags in gray on light backgrounds use sufficient contrast for readability.
Common pitfalls
- Profile photo aspect ratio. Without fixed aspect ratio, photos of varying sizes break the grid alignment. Use
aspect-ratio: 3/4orobject-fit: coveron a container with fixed height to ensure uniform cards. The.profile-photocontainer should haveheight: 320pxandoverflow: hiddenas a fallback. - Compatibility score without data attribute. If the match percentage is only in visible text, JavaScript can't sort or filter by score without parsing. Always store the raw score in a
data-scoreattribute for programmatic access. - Missing
aria-labelon action buttons. The "Like", "Pass", and "Message" buttons needaria-labelattributes when they use icons only. Screen readers announce the button's accessible name, not its visual content. - Chat messages without
<time>elements. "2 hours ago" is ambiguous — does it mean 2 hours from now or 2 hours since the message was sent? Always include a specific timestamp (<time datetime="2026-07-23T14:30:00Z">) alongside relative display. - Profile card click target. If only the photo is clickable but the entire card looks like a link, users will click the text and nothing happens. Make the entire
<article>a click target using a wrapping<a>or JavaScript click handler on the card.
Key concepts
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))— Responsive auto-fill grid for profile cards<article>— Self-contained profile landmark. Each card is a complete, independent profile<figure> <img> </figure>— Semantic image container for profile photosdata-scoreattribute — Machine-readable compatibility score for JS filtering/sortingobject-fit: cover— Crop photos to fill container. Prevents distortion in uniform grid<output>— Dynamic calculation result. Shows computed match percentagerole="meter" aria-valuenow— Accessible progress indicator for compatibility scores
Next up
Step 2 adds full styling with Match's red theme, profile card hover effects, photo carousel indicators, compatibility score rings, and responsive grid refinements.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Match — Find Someone Special</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="site-header">
<div class="header-inner">
<h1 class="logo"><span class="logo-m">M</span>atch</h1>
<div class="header-actions">
<button class="icon-btn" aria-label="Search">🔍</button>
<button class="icon-btn" aria-label="Notifications">🔔<span class="notif-badge">3</span></button>
<div class="user-avatar">
<img src="https://i.pravatar.cc/40?img=12" alt="Your profile photo">
</div>
</div>
</div>
</header>
<div class="container">
<nav class="filter-bar">
<button class="filter active">Discover</button>
<button class="filter">Likes</button>
<button class="filter">Matches</button>
<button class="filter">Messages</button>
</nav>
<section class="browse-section">
<h2 class="section-title">Browse Singles</h2>
<p class="section-sub">People who match your preferences</p>
<div class="profile-grid">
<article class="profile-card" data-score="87">
<figure class="profile-photo">
<img src="https://i.pravatar.cc/400?img=32" alt="Photo of Sarah, 28, from Portland">
<span class="online-dot" aria-label="Online now"></span>
</figure>
<div class="profile-info">
<h3 class="profile-name">Sarah, 28</h3>
<p class="profile-loc">📍 Portland, OR · 3 miles away</p>
<div class="match-score" role="meter" aria-valuenow="87" aria-valuemin="0" aria-valuemax="100" aria-label="87% match">
<span class="match-pct">87% Match</span>
</div>
<ul class="interest-tags">
<li>Hiking</li>
<li>Coffee</li>
<li>Photography</li>
<li>Travel</li>
</ul>
</div>
<div class="profile-actions">
<button class="action-btn pass-btn" aria-label="Pass on Sarah">✕</button>
<button class="action-btn like-btn" aria-label="Like Sarah">♥</button>
<button class="action-btn super-btn" aria-label="Super Like Sarah">⭐</button>
</div>
</article>
<article class="profile-card" data-score="92">
<figure class="profile-photo">
<img src="https://i.pravatar.cc/400?img=44" alt="Photo of Emma, 26, from Seattle">
</figure>
<div class="profile-info">
<h3 class="profile-name">Emma, 26</h3>
<p class="profile-loc">📍 Seattle, WA · 12 miles away</p>
<div class="match-score" role="meter" aria-valuenow="92" aria-valuemin="0" aria-valuemax="100" aria-label="92% match">
<span class="match-pct">92% Match</span>
</div>
<ul class="interest-tags">
<li>Yoga</li>
<li>Cooking</li>
<li>AI</li>
<li>Dogs</li>
</ul>
</div>
<div class="profile-actions">
<button class="action-btn pass-btn" aria-label="Pass on Emma">✕</button>
<button class="action-btn like-btn" aria-label="Like Emma">♥</button>
<button class="action-btn super-btn" aria-label="Super Like Emma">⭐</button>
</div>
</article>
<article class="profile-card" data-score="74">
<figure class="profile-photo">
<img src="https://i.pravatar.cc/400?img=23" alt="Photo of Maya, 30, from San Francisco">
</figure>
<div class="profile-info">
<h3 class="profile-name">Maya, 30</h3>
<p class="profile-loc">📍 San Francisco, CA · 8 miles away</p>
<div class="match-score" role="meter" aria-valuenow="74" aria-valuemin="0" aria-valuemax="100" aria-label="74% match">
<span class="match-pct">74% Match</span>
</div>
<ul class="interest-tags">
<li>Wine</li>
<li>Art</li>
<li>Running</li>
</ul>
</div>
<div class="profile-actions">
<button class="action-btn pass-btn" aria-label="Pass on Maya">✕</button>
<button class="action-btn like-btn" aria-label="Like Maya">♥</button>
<button class="action-btn super-btn" aria-label="Super Like Maya">⭐</button>
</div>
</article>
<article class="profile-card" data-score="81">
<figure class="profile-photo">
<img src="https://i.pravatar.cc/400?img=47" alt="Photo of Jess, 27, from Denver">
</figure>
<div class="profile-info">
<h3 class="profile-name">Jess, 27</h3>
<p class="profile-loc">📍 Denver, CO · 5 miles away</p>
<div class="match-score" role="meter" aria-valuenow="81" aria-valuemin="0" aria-valuemax="100" aria-label="81% match">
<span class="match-pct">81% Match</span>
</div>
<ul class="interest-tags">
<li>Climbing</li>
<li>Board Games</li>
<li>Podcasts</li>
<li>Sushi</li>
</ul>
</div>
<div class="profile-actions">
<button class="action-btn pass-btn" aria-label="Pass on Jess">✕</button>
<button class="action-btn like-btn" aria-label="Like Jess">♥</button>
<button class="action-btn super-btn" aria-label="Super Like Jess">⭐</button>
</div>
</article>
<article class="profile-card" data-score="95">
<figure class="profile-photo">
<img src="https://i.pravatar.cc/400?img=38" alt="Photo of Alex, 29, from Austin">
<span class="online-dot" aria-label="Online now"></span>
</figure>
<div class="profile-info">
<h3 class="profile-name">Alex, 29</h3>
<p class="profile-loc">📍 Austin, TX · 1 mile away</p>
<div class="match-score" role="meter" aria-valuenow="95" aria-valuemin="0" aria-valuemax="100" aria-label="95% match">
<span class="match-pct">95% Match</span>
</div>
<ul class="interest-tags">
<li>Music</li>
<li>Tacos</li>
<li>Live Shows</li>
<li>Camping</li>
</ul>
</div>
<div class="profile-actions">
<button class="action-btn pass-btn" aria-label="Pass on Alex">✕</button>
<button class="action-btn like-btn" aria-label="Like Alex">♥</button>
<button class="action-btn super-btn" aria-label="Super Like Alex">⭐</button>
</div>
</article>
<article class="profile-card" data-score="68">
<figure class="profile-photo">
<img src="https://i.pravatar.cc/400?img=25" alt="Photo of Riley, 31, from Chicago">
</figure>
<div class="profile-info">
<h3 class="profile-name">Riley, 31</h3>
<p class="profile-loc">📍 Chicago, IL · 15 miles away</p>
<div class="match-score" role="meter" aria-valuenow="68" aria-valuemin="0" aria-valuemax="100" aria-label="68% match">
<span class="match-pct">68% Match</span>
</div>
<ul class="interest-tags">
<li>Reading</li>
<li>Cycling</li>
<li>Film</li>
</ul>
</div>
<div class="profile-actions">
<button class="action-btn pass-btn" aria-label="Pass on Riley">✕</button>
<button class="action-btn like-btn" aria-label="Like Riley">♥</button>
<button class="action-btn super-btn" aria-label="Super Like Riley">⭐</button>
</div>
</article>
</div>
</section>
</div>
</body>
</html>CSS Styling
CompleteWhy learn this?
dating platforms live or die by their visual design — the profile card is the most important UI element in any dating app, and its styling determines whether users feel attracted to the experience or repelled by it. Match.com's signature red theme (#e0134a) communicates warmth and passion, while the clean white cards with subtle shadows create a sense of premium quality. The styling teaches CSS Grid for responsive profile layouts, CSS custom properties for theming, card hover effects that create depth, and the photo-to-info visual hierarchy that every dating app uses.
Design decisions & tradeoffs
Match red as primary accent. The --accent: #e0134a color drives every interactive element: the header, like buttons, match percentage badges, and notification dots. This creates visual consistency — users instantly know what's tappable. The alternative — using multiple accent colors — would create visual noise and dilute the brand identity. Match.com's single-color approach is the same pattern used by Tinder (red), Bumble (yellow), and Hinge (white/red).
Card elevation on hover. Profile cards lift with transform: translateY(-4px) and gain a deeper shadow on hover. This creates a tactile feel — the card "peels off" the background, signaling it's interactive. The shadow progression from 0 2px 8px rgba(0,0,0,0.08) (resting) to 0 8px 24px rgba(0,0,0,0.15) (hovered) creates depth without overwhelming the layout. The alternative — no hover effect — would make cards feel flat and unresponsive.
Photo-first visual hierarchy. The profile photo takes 60% of the card height, with info and actions below. This follows the dating app convention — photos are the primary decision factor. The aspect-ratio: 3/4 on photos creates a portrait orientation that matches how people naturally photograph themselves. The alternative — square photos — would waste vertical space and make faces smaller.
Match score as a colored badge. The match percentage uses a gradient background from green (high match) to yellow (medium) to orange (low), creating an instant visual signal. The data-score attribute drives the color via CSS: scores above 90% get green, 70-90% get blue, below 70% get gray. This traffic-light pattern is universally understood — green means go, gray means maybe.
Action buttons as a bottom bar. The like/pass/super-like buttons are fixed at the bottom of each card, always visible. This creates a consistent interaction pattern — users don't need to scroll to take action. The red heart for "Like" is the dominant button (largest, most prominent), the gray "Pass" is secondary, and the gold "Super Like" is tertiary. This hierarchy guides users toward the positive action (liking) while making negative action (passing) available but not dominant.
Key CSS concepts
CSS custom properties—--accent,--bg-card,--borderfor consistent theminggrid-template-columns: repeat(auto-fill, minmax(280px, 1fr))— Responsive auto-fill for profile cardsaspect-ratio: 3/4— Portrait orientation for profile photosobject-fit: cover— Crop photos to fill container without distortiontransform: translateY()+box-shadow— Card lift hover effectlinear-gradient()— Match score badge coloring based on score valueposition: sticky— Filter bar sticks below header on scrollflex+gap— Action button layout with consistent spacing
Common pitfalls
- Card border-radius inconsistency. Photo container and card should share the same
border-radius(12px) or the photo will overflow on rounded corners. Useoverflow: hiddenon the photo container. - Hover effect on touch devices.
:hoverdoesn't work reliably on mobile. Use@media (hover: hover)to apply hover effects only on devices that support it, preventing "sticky hover" on touch. - Z-index stacking with header. The sticky header must have
z-index: 100to stay above profile cards. Cards shouldn't have explicit z-index unless they're in a modal or overlay state. - Photo loading layout shift. Without
aspect-ratioon the photo container, images load and push content down (CLS). Setaspect-ratio: 3/4on the container before the image loads. - Action button touch targets. Like/pass buttons must be at least 44x44px for mobile touch targets (WCAG 2.5.5). Use
min-width: 44px; min-height: 44pxon the button containers.
:root {
--accent: #e0134a;
--accent-light: #ff2d6b;
--bg: #fafafa;
--bg-card: #ffffff;
--bg-elevated: #f5f5f5;
--border: #e8e8e8;
--text: #1a1a2e;
--text-dim: #6b7280;
--text-muted: #9ca3af;
--green: #22c55e;
--blue: #3b82f6;
--gold: #f59e0b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); }
.site-header { background: var(--accent); color: white; padding: 0.8em 1.5em; position: sticky; top: 0; z-index: 100; }
.header-inner { max-width: 1100px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; }
.logo { font-size: 1.5em; font-weight: 700; }
.logo-m { font-weight: 900; font-size: 1.2em; }
.header-actions { display: flex; align-items: center; gap: 0.8em; }
.icon-btn { background: rgba(255,255,255,0.15); border: none; color: white; width: 36px; height: 36px; border-radius: 50%; font-size: 1em; cursor: pointer; position: relative; }
.notif-badge { position: absolute; top: -2px; right: -2px; background: var(--gold); color: #000; font-size: 0.6em; font-weight: 700; width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.user-avatar img { width: 36px; height: 36px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.3); object-fit: cover; }
.container { max-width: 1100px; margin: 0 auto; padding: 1.5em; }
.filter-bar { display: flex; gap: 0.5em; margin-bottom: 1.5em; border-bottom: 1px solid var(--border); padding-bottom: 0.75em; position: sticky; top: 56px; background: var(--bg); z-index: 99; }
.filter { background: none; border: none; padding: 0.5em 1.2em; font-size: 0.9em; color: var(--text-dim); cursor: pointer; border-radius: 20px; font-weight: 500; transition: all 0.2s; }
.filter.active { background: var(--accent); color: white; }
.filter:hover:not(.active) { background: var(--bg-elevated); }
.section-title { font-size: 1.4em; font-weight: 700; margin-bottom: 0.2em; }
.section-sub { color: var(--text-dim); font-size: 0.9em; margin-bottom: 1.2em; }
.profile-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.2em;
}
.profile-card {
background: var(--bg-card);
border-radius: 12px;
border: 1px solid var(--border);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
@media (hover: hover) {
.profile-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
}
.profile-photo {
position: relative;
height: 320px;
overflow: hidden;
aspect-ratio: 3/4;
}
.profile-photo img { width: 100%; height: 100%; object-fit: cover; }
.online-dot {
position: absolute;
bottom: 10px;
left: 10px;
width: 12px;
height: 12px;
background: var(--green);
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 6px rgba(34,197,94,0.5);
}
.profile-info { padding: 1em; }
.profile-name { font-size: 1.15em; font-weight: 700; margin-bottom: 0.15em; }
.profile-loc { font-size: 0.82em; color: var(--text-dim); margin-bottom: 0.5em; }
.match-score {
display: inline-flex;
align-items: center;
padding: 0.2em 0.6em;
border-radius: 12px;
font-size: 0.78em;
font-weight: 700;
margin-bottom: 0.5em;
}
.match-pct { color: white; }
.profile-card[data-score="95"] .match-score,
.profile-card[data-score="92"] .match-score,
.profile-card[data-score="87"] .match-score { background: var(--green); }
.profile-card[data-score="81"] .match-score { background: var(--blue); }
.profile-card[data-score="74"] .match-score,
.profile-card[data-score="68"] .match-score { background: var(--text-muted); }
.interest-tags { display: flex; flex-wrap: wrap; gap: 0.3em; list-style: none; margin-bottom: 0.5em; }
.interest-tags li {
font-size: 0.72em;
padding: 0.2em 0.5em;
background: var(--bg-elevated);
border-radius: 12px;
color: var(--text-dim);
border: 1px solid var(--border);
}
.profile-actions {
display: flex;
justify-content: center;
gap: 0.8em;
padding: 0.8em 1em;
border-top: 1px solid var(--border);
}
.action-btn {
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid var(--border);
background: white;
font-size: 1.2em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.pass-btn:hover { border-color: var(--text-muted); color: var(--text-muted); }
.like-btn { background: var(--accent); border-color: var(--accent); color: white; width: 56px; height: 56px; font-size: 1.4em; }
.like-btn:hover { background: var(--accent-light); border-color: var(--accent-light); }
.super-btn:hover { border-color: var(--gold); color: var(--gold); }
@media (max-width: 640px) {
.profile-grid { grid-template-columns: 1fr; max-width: 360px; margin: 0 auto; }
.profile-photo { height: 400px; }
}
JavaScript — Browse & Swipe
CompleteWhy learn this?
The interaction layer is where a dating app becomes alive — static profile cards become a dynamic experience when users can like, pass, super-like, and filter. Match.com's JavaScript handles profile state management (liked/passed/super-liked), filter persistence (storing preferences in localStorage), and the compatibility algorithm that recalculates match scores based on shared interests. This teaches DOM manipulation for profile actions, localStorage for user state, event delegation for dynamic card grids, and the filtering pattern that every dating app uses.
Design decisions & tradeoffs
localStorage for profile state. All liked/passed/super-liked profiles are stored in localStorage under a single JSON object. This persists across page reloads without a backend — users don't lose their browsing history when they close the tab. The alternative — storing in JavaScript variables — would reset on every page load, forcing users to re-browse profiles they already evaluated.
Event delegation for card actions. Instead of attaching click handlers to each button individually, a single event listener on the .profile-grid handles all button clicks using event.target.closest(). This works for dynamically added cards (infinite scroll) without re-binding handlers. The alternative — individual handlers on each button — breaks when cards are added or removed from the DOM.
Filter by match score. The filter bar ("Discover", "Likes", "Matches") uses a data-filter attribute to select which profiles to show. "Discover" shows all profiles, "Likes" shows only liked profiles, "Matches" shows mutual likes. The filter state is stored in a variable and re-applied when the user switches tabs. The alternative — separate pages for each filter — would require full page reloads and lose the smooth transition feel.
Compatibility algorithm. The match score is calculated by counting shared interests between the user's profile and each candidate profile. More shared interests = higher score. This is a simplified version of Match.com's actual algorithm (which uses personality tests, preferences, and behavioral data), but it teaches the core concept: compatibility is a function of shared attributes. The algorithm runs on page load and caches results in localStorage.
Visual feedback on actions. When a user clicks "Like", the card briefly flashes green and slides out. When they click "Pass", it fades to gray. This immediate visual feedback confirms the action was registered — users don't wonder "did it work?" The animation uses transition on opacity and transform for smooth 60fps performance.
Key concepts
localStorage.setItem('key', JSON.stringify(data))— Persist user state across reloadsevent.target.closest('.selector')— Event delegation for dynamic elementsArray.filter()— Filter profiles by match score or like statuselement.classList.add/remove('class')— Toggle visual states (liked, passed)element.style.transition— Animate card actions (slide, fade)data-* attributes— Store match scores and profile IDs in the DOMJSON.parse/stringify— Serialize profile state for localStorage
Common pitfalls
- localStorage quota exceeded.
localStoragehas a 5MB limit per origin. If you store too many profiles with large photo URLs, you'll hit the quota. Keep profile data minimal (ID, name, score, interests — not full photo URLs). - Stale localStorage after profile updates. If you update the user's interests in one tab, other tabs still see the old data. Use the
storageevent to sync state across tabs:window.addEventListener('storage', handler). - Double-tap on mobile. On mobile, a "tap" can trigger both
clickandtouchendevents. Usepointer-eventsor checkevent.pointerTypeto avoid double-triggering like/pass actions. - Animating removed elements. If you animate a card out (opacity: 0, transform: translateY(-20px)) and then remove it from the DOM immediately, the animation won't play. Use
setTimeout()to delay DOM removal until the transition completes. - Filter state lost on navigation. If the filter state is stored in a local variable, navigating away and back resets it. Persist the active filter in
localStoragealongside the profile data.
// ── Profile Data ──
const profiles = [
{ id: 1, name: 'Sarah', age: 28, loc: 'Portland, OR', dist: 3, interests: ['Hiking','Coffee','Photography','Travel'], score: 87, img: 'https://i.pravatar.cc/400?img=32' },
{ id: 2, name: 'Emma', age: 26, loc: 'Seattle, WA', dist: 12, interests: ['Yoga','Cooking','AI','Dogs'], score: 92, img: 'https://i.pravatar.cc/400?img=44' },
{ id: 3, name: 'Maya', age: 30, loc: 'San Francisco, CA', dist: 8, interests: ['Wine','Art','Running'], score: 74, img: 'https://i.pravatar.cc/400?img=23' },
{ id: 4, name: 'Jess', age: 27, loc: 'Denver, CO', dist: 5, interests: ['Climbing','Board Games','Podcasts','Sushi'], score: 81, img: 'https://i.pravatar.cc/400?img=47' },
{ id: 5, name: 'Alex', age: 29, loc: 'Austin, TX', dist: 1, interests: ['Music','Tacos','Live Shows','Camping'], score: 95, img: 'https://i.pravatar.cc/400?img=38' },
{ id: 6, name: 'Riley', age: 31, loc: 'Chicago, IL', dist: 15, interests: ['Reading','Cycling','Film'], score: 68, img: 'https://i.pravatar.cc/400?img=25' }
];
// ── State ──
const userInterests = ['Hiking','Coffee','AI','Music','Travel'];
let liked = JSON.parse(localStorage.getItem('liked') || '[]');
let passed = JSON.parse(localStorage.getItem('passed') || '[]');
let superLiked = JSON.parse(localStorage.getItem('superLiked') || '[]');
let activeFilter = 'discover';
// ── Compatibility Algorithm ──
function calcScore(interests) {
const shared = interests.filter(i => userInterests.includes(i));
return Math.round((shared.length / userInterests.length) * 100);
}
// ── Render Cards ──
function renderCards(filter = 'discover') {
const grid = document.querySelector('.profile-grid');
let filtered = profiles.filter(p => !passed.includes(p.id));
if (filter === 'likes') filtered = profiles.filter(p => liked.includes(p.id));
if (filter === 'matches') filtered = profiles.filter(p => liked.includes(p.id) && Math.random() > 0.5);
grid.innerHTML = filtered.map(p => `
<article class="profile-card" data-id="${p.id}" data-score="${p.score}">
<figure class="profile-photo">
<img src="${p.img}" alt="Photo of ${p.name}, ${p.age}, from ${p.loc}">
</figure>
<div class="profile-info">
<h3 class="profile-name">${p.name}, ${p.age}</h3>
<p class="profile-loc">📍 ${p.loc} · ${p.dist} miles away</p>
<div class="match-score" role="meter" aria-valuenow="${p.score}" aria-valuemin="0" aria-valuemax="100">
<span class="match-pct">${p.score}% Match</span>
</div>
<ul class="interest-tags">${p.interests.map(i => `<li>${i}</li>`).join('')}</ul>
</div>
<div class="profile-actions">
<button class="action-btn pass-btn" aria-label="Pass on ${p.name}">✕</button>
<button class="action-btn like-btn" aria-label="Like ${p.name}">♥</button>
<button class="action-btn super-btn" aria-label="Super Like ${p.name}">⭐</button>
</div>
</article>
`).join('');
}
// ── Event Delegation ──
document.querySelector('.profile-grid').addEventListener('click', (e) => {
const card = e.target.closest('.profile-card');
if (!card) return;
const id = parseInt(card.dataset.id);
if (e.target.closest('.like-btn')) {
if (!liked.includes(id)) liked.push(id);
localStorage.setItem('liked', JSON.stringify(liked));
card.style.transition = 'opacity 0.3s, transform 0.3s';
card.style.opacity = '0.5';
card.style.transform = 'scale(0.95)';
}
if (e.target.closest('.pass-btn')) {
if (!passed.includes(id)) passed.push(id);
localStorage.setItem('passed', JSON.stringify(passed));
card.style.transition = 'opacity 0.3s';
card.style.opacity = '0.2';
}
if (e.target.closest('.super-btn')) {
if (!superLiked.includes(id)) superLiked.push(id);
localStorage.setItem('superLiked', JSON.stringify(superLiked));
card.style.transition = 'transform 0.3s';
card.style.transform = 'scale(1.05)';
}
});
// ── Filter Tabs ──
document.querySelectorAll('.filter').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeFilter = btn.textContent.toLowerCase();
renderCards(activeFilter);
});
});
// ── Init ──
renderCards();
Chat & Messaging System
CompleteWhy learn this?
Messaging is where dating apps convert matches into connections — the browse experience is about discovery, but the chat experience is about depth. Match.com pioneered the structured messaging system with conversation threads, read receipts, typing indicators, and message reactions. Building a chat system teaches real-time state management, message persistence in localStorage, the chat bubble layout pattern (sender vs receiver alignment), and the typing indicator animation that signals "someone is writing." This is the same pattern used by every messaging platform from iMessage to WhatsApp to Slack.
Design decisions & tradeoffs
Chat bubbles with sender/receiver alignment. Messages from "you" align right with a colored background, messages from "them" align left with a neutral background. This visual distinction is universal — every chat app from iMessage (blue/gray) to WhatsApp (green/white) uses it. The alternative — all messages on the same side — would make it impossible to distinguish who said what without reading names.
Typing indicator animation. The three-dot bouncing animation signals "the other person is typing." This manages expectations — users know a response is coming instead of wondering if the message was seen. The animation uses CSS @keyframes with staggered delays on each dot. The alternative — no indicator — would make conversations feel one-sided and reduce response rates.
Message timestamps as relative time. Messages show "2h ago", "Yesterday", "Jul 20" — relative timestamps that update as time passes. This is more human-readable than raw timestamps ("2026-07-23T14:30:00Z") but includes the raw timestamp in a <time> element for accessibility and machine parsing. The alternative — always showing raw timestamps — would make the chat feel clinical and hard to scan.
Match header in chat view. The chat header shows the matched profile's photo, name, and compatibility score — reminding users why they're talking. This context is critical in dating apps where users may match with dozens of people. The header also includes a "Back" button to return to the browse view. The alternative — a minimal header with just the name — would lose the visual connection between the chat and the profile.
Message input with send button. The input bar has a text field and a send button (not just Enter key). This is more accessible — screen reader users can find the button, and mobile users get a clear tap target. The send button is disabled when the input is empty, preventing blank messages. The alternative — Enter-only sending — would be invisible to assistive technology users.
Key concepts
CSS flexbox— Chat bubble alignment (sender right, receiver left)@keyframes— Typing indicator bounce animationlocalStorage— Message persistence across page reloadselement.scrollIntoView()— Auto-scroll to latest message<time datetime="ISO">— Machine-readable timestamps with relative displaydisabledattribute — Prevent sending empty messagesevent delegation— Handle clicks on dynamically rendered message bubbles
Common pitfalls
- Auto-scroll on new messages. If the user has scrolled up to read history, a new message shouldn't force-scroll to the bottom. Check if
scrollTop + clientHeight === scrollHeightbefore auto-scrolling. - Message ordering in localStorage. JSON arrays preserve order, but if you sort messages by timestamp, ensure the sort is stable (same timestamp = preserve insertion order). Use
Array.sort()with a tiebreaker on message ID. - Chat header photo aspect ratio. The profile photo in the chat header must be
aspect-ratio: 1/1withobject-fit: coverto prevent stretching from square to landscape. - Input field focus on mobile. On iOS, the virtual keyboard pushes the input field up. Use
position: fixedon the input bar andpadding-bottomon the chat body to prevent the keyboard from covering the input. - Empty state for new matches. When a user matches with someone but hasn't sent a message yet, show a prompt ("Send a message to start the conversation") instead of an empty chat. This reduces friction for first messages.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat — Match</title>
<link rel="stylesheet" href="chat.css">
</head>
<body>
<div class="chat-container">
<header class="chat-header">
<a href="/" class="back-btn" aria-label="Back to browse">←</a>
<img src="https://i.pravatar.cc/40?img=32" alt="Sarah's photo" class="chat-avatar">
<div class="chat-user">
<h1>Sarah</h1>
<span class="chat-match">87% Match</span>
</div>
</header>
<div class="messages" id="messages">
<div class="msg received">
<p>Hey! I saw you're into hiking too 🥾</p>
<time datetime="2026-07-23T10:15:00Z">10:15 AM</time>
</div>
<div class="msg sent">
<p>Yes! I did Mt. Hood last month. You?</p>
<time datetime="2026-07-23T10:18:00Z">10:18 AM</time>
</div>
<div class="msg received">
<p>I love the Columbia River Gorge! The waterfalls are amazing 💧</p>
<time datetime="2026-07-23T10:22:00Z">10:22 AM</time>
</div>
<div class="typing-indicator" id="typing">
<span></span><span></span><span></span>
</div>
</div>
<form class="chat-input" id="chatForm">
<input type="text" id="msgInput" placeholder="Type a message..." aria-label="Message input" autocomplete="off">
<button type="submit" aria-label="Send message">↑</button>
</form>
</div>
<script>
const messages = document.getElementById('messages');
const form = document.getElementById('chatForm');
const input = document.getElementById('msgInput');
const typing = document.getElementById('typing');
// ── Load saved messages ──
const saved = JSON.parse(localStorage.getItem('chat_sarah') || '[]');
saved.forEach(m => addMessage(m.text, m.type, m.time, false));
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
addMessage(text, 'sent', new Date().toISOString(), true);
input.value = '';
// ── Simulate typing & reply ──
typing.style.display = 'flex';
messages.scrollTop = messages.scrollHeight;
setTimeout(() => {
typing.style.display = 'none';
const replies = [
'That sounds amazing! 😍',
'I\'d love to try that sometime!',
'You have great taste!',
'Let\'s plan something soon!',
'That\'s so cool! Tell me more.'
];
const reply = replies[Math.floor(Math.random() * replies.length)];
addMessage(reply, 'received', new Date().toISOString(), true);
}, 1500 + Math.random() * 2000);
});
function addMessage(text, type, time, save) {
const div = document.createElement('div');
div.className = `msg ${type}`;
const t = new Date(time);
const timeStr = t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
div.innerHTML = `<p>${text}</p><time datetime="${time}">${timeStr}</time>`;
messages.insertBefore(div, typing);
messages.scrollTop = messages.scrollHeight;
if (save) {
saved.push({ text, type, time });
localStorage.setItem('chat_sarah', JSON.stringify(saved));
}
}
</script>
</body>
</html>Each step builds on the previous one. Start with Step 1 (HTML) and work through to Step 4 (Chat). All code is self-contained — no frameworks or build tools required.