Back to Frontend
Evergreen··28 min read

Vanilla JS Components for Frontend Interviews

Build production-quality UI components from scratch using only HTML, CSS, and vanilla JavaScript — image carousel, modal dialog, star rating, and more. Interview-ready implementations with accessibility.

javascriptvanilla-jsinterviewscomponentshtmlcss
Share

Vanilla JS Components for Frontend Interviews

Frontend interviews love asking you to build UI components from scratch — no React, no frameworks, just vanilla HTML, CSS, and JavaScript. This post covers the most commonly asked components with clean, interview-ready implementations.

Each component follows these principles:

  • Accessible — keyboard navigation, ARIA attributes, screen reader support
  • Clean separation — HTML structure, CSS styling, JS behavior
  • Edge cases handled — the details interviewers look for

A classic interview question. Build a carousel that cycles through images with previous/next buttons, dot indicators, and keyboard support.

What Interviewers Look For

  • Handling wrap-around (last slide → first slide)
  • Keyboard accessibility (arrow keys)
  • Smooth transitions
  • Auto-play with pause on hover (bonus)

HTML

<div class="carousel" role="region" aria-label="Image carousel" aria-roledescription="carousel">
  <div class="carousel-viewport">
    <div class="carousel-track">
      <div class="carousel-slide active" role="group" aria-roledescription="slide" aria-label="Slide 1 of 4">
        <img src="https://picsum.photos/id/10/600/300" alt="Mountain landscape" />
      </div>
      <div class="carousel-slide" role="group" aria-roledescription="slide" aria-label="Slide 2 of 4">
        <img src="https://picsum.photos/id/20/600/300" alt="Ocean view" />
      </div>
      <div class="carousel-slide" role="group" aria-roledescription="slide" aria-label="Slide 3 of 4">
        <img src="https://picsum.photos/id/30/600/300" alt="Forest path" />
      </div>
      <div class="carousel-slide" role="group" aria-roledescription="slide" aria-label="Slide 4 of 4">
        <img src="https://picsum.photos/id/40/600/300" alt="City skyline" />
      </div>
    </div>
  </div>
 
  <button class="carousel-btn carousel-btn--prev" aria-label="Previous slide">&larr;</button>
  <button class="carousel-btn carousel-btn--next" aria-label="Next slide">&rarr;</button>
 
  <div class="carousel-dots" role="tablist" aria-label="Slide navigation">
    <button class="carousel-dot active" role="tab" aria-selected="true" aria-label="Go to slide 1"></button>
    <button class="carousel-dot" role="tab" aria-selected="false" aria-label="Go to slide 2"></button>
    <button class="carousel-dot" role="tab" aria-selected="false" aria-label="Go to slide 3"></button>
    <button class="carousel-dot" role="tab" aria-selected="false" aria-label="Go to slide 4"></button>
  </div>
</div>

CSS

.carousel {
  position: relative;
  max-width: 600px;
  margin: 0 auto;
  overflow: hidden;
  border-radius: 8px;
}
 
.carousel-viewport {
  overflow: hidden;
}
 
.carousel-track {
  display: flex;
  transition: transform 0.4s ease-in-out;
}
 
.carousel-slide {
  min-width: 100%;
  flex-shrink: 0;
}
 
.carousel-slide img {
  width: 100%;
  height: 300px;
  object-fit: cover;
  display: block;
}
 
/* Navigation buttons */
.carousel-btn {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  background: rgba(0, 0, 0, 0.5);
  color: white;
  border: none;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  cursor: pointer;
  font-size: 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 2;
}
 
.carousel-btn:hover {
  background: rgba(0, 0, 0, 0.8);
}
 
.carousel-btn--prev { left: 10px; }
.carousel-btn--next { right: 10px; }
 
/* Dot indicators */
.carousel-dots {
  display: flex;
  justify-content: center;
  gap: 8px;
  padding: 12px 0;
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
}
 
.carousel-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  border: 2px solid white;
  background: transparent;
  cursor: pointer;
  padding: 0;
}
 
.carousel-dot.active {
  background: white;
}

JavaScript

class Carousel {
  constructor(element) {
    this.el = element;
    this.track = element.querySelector('.carousel-track');
    this.slides = [...element.querySelectorAll('.carousel-slide')];
    this.dots = [...element.querySelectorAll('.carousel-dot')];
    this.prevBtn = element.querySelector('.carousel-btn--prev');
    this.nextBtn = element.querySelector('.carousel-btn--next');
 
    this.currentIndex = 0;
    this.totalSlides = this.slides.length;
    this.autoPlayInterval = null;
 
    this.bindEvents();
    this.startAutoPlay();
  }
 
  bindEvents() {
    this.prevBtn.addEventListener('click', () => this.goTo(this.currentIndex - 1));
    this.nextBtn.addEventListener('click', () => this.goTo(this.currentIndex + 1));
 
    this.dots.forEach((dot, i) => {
      dot.addEventListener('click', () => this.goTo(i));
    });
 
    // Keyboard navigation
    this.el.addEventListener('keydown', (e) => {
      if (e.key === 'ArrowLeft') this.goTo(this.currentIndex - 1);
      if (e.key === 'ArrowRight') this.goTo(this.currentIndex + 1);
    });
 
    // Pause auto-play on hover
    this.el.addEventListener('mouseenter', () => this.stopAutoPlay());
    this.el.addEventListener('mouseleave', () => this.startAutoPlay());
 
    // Pause on focus for accessibility
    this.el.addEventListener('focusin', () => this.stopAutoPlay());
    this.el.addEventListener('focusout', () => this.startAutoPlay());
  }
 
  goTo(index) {
    // Wrap around
    if (index < 0) index = this.totalSlides - 1;
    if (index >= this.totalSlides) index = 0;
 
    this.currentIndex = index;
    this.track.style.transform = `translateX(-$\{index * 100\}%)`;
 
    // Update active states
    this.slides.forEach((slide, i) => {
      slide.classList.toggle('active', i === index);
      slide.setAttribute('aria-label', `Slide $\{i + 1\} of $\{this.totalSlides\}`);
    });
 
    this.dots.forEach((dot, i) => {
      dot.classList.toggle('active', i === index);
      dot.setAttribute('aria-selected', i === index);
    });
  }
 
  startAutoPlay(interval = 4000) {
    this.stopAutoPlay();
    this.autoPlayInterval = setInterval(() => {
      this.goTo(this.currentIndex + 1);
    }, interval);
  }
 
  stopAutoPlay() {
    if (this.autoPlayInterval) {
      clearInterval(this.autoPlayInterval);
      this.autoPlayInterval = null;
    }
  }
}
 
// Initialize
const carousel = new Carousel(document.querySelector('.carousel'));

Key Interview Points

  • Wrap-around logic: index < 0 goes to last slide, index >= total goes to first
  • CSS transform vs. display toggle: Transform with translateX is more performant — it triggers GPU compositing instead of layout recalculations
  • Auto-play pause: Must pause on both hover and keyboard focus (accessibility)
  • ARIA roles: aria-roledescription="carousel" and aria-roledescription="slide" give screen readers proper context

2. Modal Dialog

Modals are deceptively complex. The tricky parts are focus trapping, scroll locking, and closing behavior.

What Interviewers Look For

  • Focus trap (Tab cycles within modal only)
  • Escape key closes modal
  • Clicking backdrop closes modal
  • Scroll lock on body
  • Return focus to trigger element on close

HTML

<button id="open-modal" class="btn">Open Modal</button>
 
<div class="modal-overlay" id="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" aria-hidden="true">
  <div class="modal-content">
    <div class="modal-header">
      <h2 id="modal-title">Modal Title</h2>
      <button class="modal-close" aria-label="Close modal">&times;</button>
    </div>
    <div class="modal-body">
      <p>This is the modal content. Tab through the focusable elements — focus stays trapped inside.</p>
      <input type="text" placeholder="Try tabbing here..." />
      <textarea placeholder="And here..."></textarea>
    </div>
    <div class="modal-footer">
      <button class="btn btn--secondary modal-cancel">Cancel</button>
      <button class="btn btn--primary">Confirm</button>
    </div>
  </div>
</div>

CSS

.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.2s ease, visibility 0.2s ease;
}
 
.modal-overlay.open {
  opacity: 1;
  visibility: visible;
}
 
.modal-content {
  background: white;
  border-radius: 12px;
  width: 90%;
  max-width: 500px;
  max-height: 80vh;
  overflow-y: auto;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  transform: scale(0.95) translateY(10px);
  transition: transform 0.2s ease;
}
 
.modal-overlay.open .modal-content {
  transform: scale(1) translateY(0);
}
 
.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px 24px;
  border-bottom: 1px solid #e5e7eb;
}
 
.modal-header h2 {
  margin: 0;
  font-size: 18px;
}
 
.modal-close {
  background: none;
  border: none;
  font-size: 24px;
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 4px;
  color: #6b7280;
}
 
.modal-close:hover {
  background: #f3f4f6;
  color: #111;
}
 
.modal-body {
  padding: 20px 24px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
 
.modal-footer {
  padding: 16px 24px;
  border-top: 1px solid #e5e7eb;
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}
 
/* Scroll lock */
body.modal-open {
  overflow: hidden;
}

JavaScript

class Modal {
  constructor(overlay) {
    this.overlay = overlay;
    this.content = overlay.querySelector('.modal-content');
    this.closeBtn = overlay.querySelector('.modal-close');
    this.cancelBtn = overlay.querySelector('.modal-cancel');
    this.triggerElement = null;
 
    this.bindEvents();
  }
 
  bindEvents() {
    this.closeBtn.addEventListener('click', () => this.close());
    if (this.cancelBtn) {
      this.cancelBtn.addEventListener('click', () => this.close());
    }
 
    // Close on backdrop click (but not content click)
    this.overlay.addEventListener('click', (e) => {
      if (e.target === this.overlay) this.close();
    });
 
    // Close on Escape
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape' && this.isOpen()) this.close();
    });
  }
 
  open(triggerElement) {
    this.triggerElement = triggerElement;
    this.overlay.classList.add('open');
    this.overlay.setAttribute('aria-hidden', 'false');
    document.body.classList.add('modal-open');
 
    // Focus the first focusable element
    const firstFocusable = this.getFocusableElements()[0];
    if (firstFocusable) firstFocusable.focus();
 
    // Trap focus
    this.overlay.addEventListener('keydown', this.handleTab);
  }
 
  close() {
    this.overlay.classList.remove('open');
    this.overlay.setAttribute('aria-hidden', 'true');
    document.body.classList.remove('modal-open');
 
    // Return focus to the trigger element
    if (this.triggerElement) {
      this.triggerElement.focus();
    }
 
    this.overlay.removeEventListener('keydown', this.handleTab);
  }
 
  isOpen() {
    return this.overlay.classList.contains('open');
  }
 
  getFocusableElements() {
    const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
    return [...this.content.querySelectorAll(selector)].filter(
      (el) => !el.disabled && el.offsetParent !== null
    );
  }
 
  handleTab = (e) => {
    if (e.key !== 'Tab') return;
 
    const focusable = this.getFocusableElements();
    const first = focusable[0];
    const last = focusable[focusable.length - 1];
 
    if (e.shiftKey) {
      // Shift+Tab: if on first element, wrap to last
      if (document.activeElement === first) {
        e.preventDefault();
        last.focus();
      }
    } else {
      // Tab: if on last element, wrap to first
      if (document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  };
}
 
// Initialize
const modal = new Modal(document.getElementById('modal'));
const openBtn = document.getElementById('open-modal');
openBtn.addEventListener('click', () => modal.open(openBtn));

Key Interview Points

  • Focus trap: The handleTab method wraps focus between first and last focusable elements — this is the most important accessibility detail
  • aria-modal="true": Tells assistive technologies that content behind the modal is inert
  • Return focus: When the modal closes, focus returns to the button that opened it — essential for keyboard users
  • Backdrop click detection: e.target === this.overlay ensures clicks on the content don't close the modal
  • Scroll lock: overflow: hidden on body prevents background scrolling while the modal is open

3. Star Rating

A seemingly simple component with subtle UX details — hover preview, click to select, keyboard support, and half-star support.

What Interviewers Look For

  • Hover preview vs. selected state
  • Keyboard accessibility (arrow keys to change rating)
  • Screen reader support
  • Handling half-stars (bonus)

HTML

<div class="star-rating" role="radiogroup" aria-label="Rating">
  <button class="star" data-value="1" role="radio" aria-checked="false" aria-label="1 star">
    <svg viewBox="0 0 24 24" width="32" height="32">
      <polygon points="12,2 15,9 22,9 17,14 18,21 12,17 6,21 7,14 2,9 9,9" />
    </svg>
  </button>
  <button class="star" data-value="2" role="radio" aria-checked="false" aria-label="2 stars">
    <svg viewBox="0 0 24 24" width="32" height="32">
      <polygon points="12,2 15,9 22,9 17,14 18,21 12,17 6,21 7,14 2,9 9,9" />
    </svg>
  </button>
  <button class="star" data-value="3" role="radio" aria-checked="false" aria-label="3 stars">
    <svg viewBox="0 0 24 24" width="32" height="32">
      <polygon points="12,2 15,9 22,9 17,14 18,21 12,17 6,21 7,14 2,9 9,9" />
    </svg>
  </button>
  <button class="star" data-value="4" role="radio" aria-checked="false" aria-label="4 stars">
    <svg viewBox="0 0 24 24" width="32" height="32">
      <polygon points="12,2 15,9 22,9 17,14 18,21 12,17 6,21 7,14 2,9 9,9" />
    </svg>
  </button>
  <button class="star" data-value="5" role="radio" aria-checked="false" aria-label="5 stars">
    <svg viewBox="0 0 24 24" width="32" height="32">
      <polygon points="12,2 15,9 22,9 17,14 18,21 12,17 6,21 7,14 2,9 9,9" />
    </svg>
  </button>
  <span class="star-rating-text" aria-live="polite">No rating</span>
</div>

CSS

.star-rating {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}
 
.star {
  background: none;
  border: none;
  cursor: pointer;
  padding: 2px;
  border-radius: 4px;
  transition: transform 0.1s ease;
}
 
.star:hover {
  transform: scale(1.15);
}
 
.star:focus-visible {
  outline: 2px solid #f59e0b;
  outline-offset: 2px;
}
 
.star svg {
  fill: #d1d5db;
  stroke: #d1d5db;
  stroke-width: 1;
  transition: fill 0.15s ease, stroke 0.15s ease;
}
 
/* Hover preview — all stars up to hovered one turn yellow */
.star-rating:hover .star svg {
  fill: #d1d5db;
  stroke: #d1d5db;
}
 
.star-rating .star.hovered svg {
  fill: #fbbf24;
  stroke: #f59e0b;
}
 
/* Selected state */
.star.selected svg {
  fill: #f59e0b;
  stroke: #d97706;
}
 
.star-rating-text {
  margin-left: 8px;
  font-size: 14px;
  color: #6b7280;
  min-width: 80px;
}

JavaScript

class StarRating {
  constructor(element) {
    this.el = element;
    this.stars = [...element.querySelectorAll('.star')];
    this.text = element.querySelector('.star-rating-text');
    this.currentRating = 0;
 
    this.labels = ['No rating', 'Poor', 'Fair', 'Good', 'Very Good', 'Excellent'];
 
    this.bindEvents();
  }
 
  bindEvents() {
    this.stars.forEach((star, index) => {
      // Hover preview
      star.addEventListener('mouseenter', () => this.preview(index + 1));
 
      // Click to select
      star.addEventListener('click', () => this.select(index + 1));
    });
 
    // Reset preview on mouse leave
    this.el.addEventListener('mouseleave', () => this.preview(0));
 
    // Keyboard support
    this.el.addEventListener('keydown', (e) => {
      const focused = this.stars.indexOf(document.activeElement);
      if (focused === -1) return;
 
      if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
        e.preventDefault();
        const next = Math.min(focused + 1, this.stars.length - 1);
        this.stars[next].focus();
        this.select(next + 1);
      }
 
      if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
        e.preventDefault();
        const prev = Math.max(focused - 1, 0);
        this.stars[prev].focus();
        this.select(prev + 1);
      }
    });
  }
 
  preview(rating) {
    this.stars.forEach((star, i) => {
      star.classList.toggle('hovered', rating > 0 && i < rating);
    });
 
    // Show preview text while hovering
    if (rating > 0) {
      this.text.textContent = this.labels[rating];
    } else {
      this.text.textContent = this.labels[this.currentRating];
    }
  }
 
  select(rating) {
    // Toggle off if clicking same rating
    if (this.currentRating === rating) {
      this.currentRating = 0;
    } else {
      this.currentRating = rating;
    }
 
    this.stars.forEach((star, i) => {
      const isSelected = i < this.currentRating;
      star.classList.toggle('selected', isSelected);
      star.setAttribute('aria-checked', isSelected);
    });
 
    this.text.textContent = this.labels[this.currentRating];
 
    // Dispatch custom event
    this.el.dispatchEvent(new CustomEvent('rating-change', {
      detail: { rating: this.currentRating }
    }));
  }
}
 
// Initialize
const rating = new StarRating(document.querySelector('.star-rating'));
 
// Listen for changes
rating.el.addEventListener('rating-change', (e) => {
  console.log('Rating:', e.detail.rating);
});

Key Interview Points

  • Hover vs. selected: Two separate visual states — hovered class for preview, selected class for committed choice
  • Mouse leave resets preview: When the mouse leaves the component, it reverts to showing the selected state
  • Toggle behavior: Clicking the same star deselects it (rating = 0)
  • aria-live="polite": The text label announces changes to screen readers without interrupting them
  • Custom event: rating-change event makes the component reusable — parent code listens for the event instead of reaching into internals

4. Accordion / Collapsible Sections

Commonly asked as a follow-up to simpler components. Key details: only one section open at a time (optional), smooth height animation, and keyboard navigation.

HTML

<div class="accordion" role="presentation">
  <div class="accordion-item">
    <button class="accordion-header" aria-expanded="false" aria-controls="panel-1" id="header-1">
      <span>What is JavaScript?</span>
      <span class="accordion-icon" aria-hidden="true">+</span>
    </button>
    <div class="accordion-panel" id="panel-1" role="region" aria-labelledby="header-1">
      <div class="accordion-content">
        <p>JavaScript is a high-level, interpreted programming language. It is one of the core technologies of the World Wide Web, alongside HTML and CSS.</p>
      </div>
    </div>
  </div>
 
  <div class="accordion-item">
    <button class="accordion-header" aria-expanded="false" aria-controls="panel-2" id="header-2">
      <span>What is the DOM?</span>
      <span class="accordion-icon" aria-hidden="true">+</span>
    </button>
    <div class="accordion-panel" id="panel-2" role="region" aria-labelledby="header-2">
      <div class="accordion-content">
        <p>The Document Object Model (DOM) is a programming interface for web documents. It represents the page so that programs can change the document structure, style, and content.</p>
      </div>
    </div>
  </div>
 
  <div class="accordion-item">
    <button class="accordion-header" aria-expanded="false" aria-controls="panel-3" id="header-3">
      <span>What is event delegation?</span>
      <span class="accordion-icon" aria-hidden="true">+</span>
    </button>
    <div class="accordion-panel" id="panel-3" role="region" aria-labelledby="header-3">
      <div class="accordion-content">
        <p>Event delegation is a technique where you attach a single event listener to a parent element to handle events for all its child elements. It works because of event bubbling in the DOM.</p>
      </div>
    </div>
  </div>
</div>

CSS

.accordion {
  max-width: 600px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  overflow: hidden;
}
 
.accordion-item + .accordion-item {
  border-top: 1px solid #e5e7eb;
}
 
.accordion-header {
  width: 100%;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 20px;
  background: white;
  border: none;
  cursor: pointer;
  font-size: 16px;
  font-weight: 500;
  text-align: left;
  color: #111;
  transition: background 0.15s ease;
}
 
.accordion-header:hover {
  background: #f9fafb;
}
 
.accordion-header:focus-visible {
  outline: 2px solid #3b82f6;
  outline-offset: -2px;
}
 
.accordion-icon {
  font-size: 20px;
  transition: transform 0.3s ease;
  color: #6b7280;
}
 
.accordion-header[aria-expanded="true"] .accordion-icon {
  transform: rotate(45deg);
}
 
.accordion-panel {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.3s ease;
}
 
.accordion-content {
  padding: 0 20px 16px;
  color: #4b5563;
  line-height: 1.6;
}

JavaScript

class Accordion {
  constructor(element, options = {}) {
    this.el = element;
    this.allowMultiple = options.allowMultiple || false;
    this.items = [...element.querySelectorAll('.accordion-item')];
    this.headers = [...element.querySelectorAll('.accordion-header')];
 
    this.bindEvents();
  }
 
  bindEvents() {
    this.headers.forEach((header) => {
      header.addEventListener('click', () => this.toggle(header));
    });
 
    // Keyboard navigation
    this.el.addEventListener('keydown', (e) => {
      const index = this.headers.indexOf(document.activeElement);
      if (index === -1) return;
 
      let target;
      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault();
          target = this.headers[(index + 1) % this.headers.length];
          break;
        case 'ArrowUp':
          e.preventDefault();
          target = this.headers[(index - 1 + this.headers.length) % this.headers.length];
          break;
        case 'Home':
          e.preventDefault();
          target = this.headers[0];
          break;
        case 'End':
          e.preventDefault();
          target = this.headers[this.headers.length - 1];
          break;
      }
 
      if (target) target.focus();
    });
  }
 
  toggle(header) {
    const isExpanded = header.getAttribute('aria-expanded') === 'true';
    const panel = document.getElementById(header.getAttribute('aria-controls'));
 
    if (!this.allowMultiple && !isExpanded) {
      // Close all other panels first
      this.headers.forEach((h) => {
        if (h !== header) this.collapse(h);
      });
    }
 
    if (isExpanded) {
      this.collapse(header);
    } else {
      this.expand(header);
    }
  }
 
  expand(header) {
    const panel = document.getElementById(header.getAttribute('aria-controls'));
    header.setAttribute('aria-expanded', 'true');
 
    // Animate to full height
    panel.style.maxHeight = panel.scrollHeight + 'px';
  }
 
  collapse(header) {
    const panel = document.getElementById(header.getAttribute('aria-controls'));
    header.setAttribute('aria-expanded', 'false');
    panel.style.maxHeight = '0';
  }
}
 
// Single-open mode (default)
const accordion = new Accordion(document.querySelector('.accordion'));
 
// Multi-open mode
// const accordion = new Accordion(document.querySelector('.accordion'), { allowMultiple: true });

Key Interview Points

  • max-height animation trick: CSS can't animate height: auto. Use scrollHeight to get the actual content height and animate max-height to that value
  • allowMultiple option: Shows you think about configurability
  • Keyboard nav: Arrow keys move between headers, Home/End jump to first/last — follows WAI-ARIA accordion pattern
  • aria-expanded + aria-controls: Links each button to its panel for screen readers

5. Dropdown Menu

A dropdown with keyboard navigation, sub-menus awareness, and click-outside-to-close behavior.

HTML

<div class="dropdown">
  <button class="dropdown-trigger" aria-haspopup="true" aria-expanded="false">
    Options
    <span class="dropdown-arrow" aria-hidden="true">&#9662;</span>
  </button>
  <ul class="dropdown-menu" role="menu">
    <li role="menuitem" tabindex="-1">Edit</li>
    <li role="menuitem" tabindex="-1">Duplicate</li>
    <li role="menuitem" tabindex="-1">Archive</li>
    <li class="dropdown-divider" role="separator"></li>
    <li role="menuitem" tabindex="-1" class="dropdown-danger">Delete</li>
  </ul>
</div>

CSS

.dropdown {
  position: relative;
  display: inline-block;
}
 
.dropdown-trigger {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 16px;
  background: white;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  color: #111;
}
 
.dropdown-trigger:hover {
  background: #f9fafb;
}
 
.dropdown-arrow {
  font-size: 10px;
  transition: transform 0.2s ease;
}
 
.dropdown-trigger[aria-expanded="true"] .dropdown-arrow {
  transform: rotate(180deg);
}
 
.dropdown-menu {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  min-width: 160px;
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  list-style: none;
  padding: 4px;
  margin: 0;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-4px);
  transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
  z-index: 100;
}
 
.dropdown-menu.open {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}
 
.dropdown-menu li[role="menuitem"] {
  padding: 8px 12px;
  cursor: pointer;
  border-radius: 4px;
  font-size: 14px;
  color: #374151;
}
 
.dropdown-menu li[role="menuitem"]:hover,
.dropdown-menu li[role="menuitem"]:focus {
  background: #f3f4f6;
  outline: none;
}
 
.dropdown-divider {
  height: 1px;
  background: #e5e7eb;
  margin: 4px 0;
}
 
.dropdown-danger {
  color: #ef4444 !important;
}

JavaScript

class Dropdown {
  constructor(element) {
    this.el = element;
    this.trigger = element.querySelector('.dropdown-trigger');
    this.menu = element.querySelector('.dropdown-menu');
    this.items = [...element.querySelectorAll('[role="menuitem"]')];
    this.focusedIndex = -1;
 
    this.bindEvents();
  }
 
  bindEvents() {
    this.trigger.addEventListener('click', () => this.toggle());
 
    // Keyboard on trigger
    this.trigger.addEventListener('keydown', (e) => {
      if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        this.open();
        this.focusItem(0);
      }
    });
 
    // Keyboard navigation within menu
    this.menu.addEventListener('keydown', (e) => {
      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault();
          this.focusItem(this.focusedIndex + 1);
          break;
        case 'ArrowUp':
          e.preventDefault();
          this.focusItem(this.focusedIndex - 1);
          break;
        case 'Home':
          e.preventDefault();
          this.focusItem(0);
          break;
        case 'End':
          e.preventDefault();
          this.focusItem(this.items.length - 1);
          break;
        case 'Escape':
          this.close();
          this.trigger.focus();
          break;
        case 'Enter':
        case ' ':
          e.preventDefault();
          if (this.focusedIndex >= 0) {
            this.items[this.focusedIndex].click();
            this.close();
          }
          break;
        case 'Tab':
          this.close();
          break;
      }
    });
 
    // Click item
    this.items.forEach((item, i) => {
      item.addEventListener('click', () => {
        console.log('Selected:', item.textContent);
        this.close();
      });
    });
 
    // Click outside to close
    document.addEventListener('click', (e) => {
      if (!this.el.contains(e.target)) {
        this.close();
      }
    });
  }
 
  toggle() {
    if (this.menu.classList.contains('open')) {
      this.close();
    } else {
      this.open();
    }
  }
 
  open() {
    this.menu.classList.add('open');
    this.trigger.setAttribute('aria-expanded', 'true');
  }
 
  close() {
    this.menu.classList.remove('open');
    this.trigger.setAttribute('aria-expanded', 'false');
    this.focusedIndex = -1;
  }
 
  focusItem(index) {
    if (index < 0) index = this.items.length - 1;
    if (index >= this.items.length) index = 0;
 
    this.focusedIndex = index;
    this.items[index].focus();
  }
}
 
// Initialize
const dropdown = new Dropdown(document.querySelector('.dropdown'));

Key Interview Points

  • Click outside: document.addEventListener('click') with this.el.contains(e.target) — a pattern used everywhere
  • aria-haspopup + aria-expanded: Tells screen readers a popup is available and its current state
  • Focus management: Arrow keys cycle through items, Escape returns focus to trigger
  • tabindex="-1" on menu items: Makes them focusable via JS but not in the natural tab order

6. Tooltip

Position-aware tooltips that flip when they'd go off-screen.

HTML

<button class="tooltip-trigger" data-tooltip="This is a helpful tooltip!" data-position="top">
  Hover me (top)
</button>
 
<button class="tooltip-trigger" data-tooltip="I appear on the right side" data-position="right">
  Hover me (right)
</button>
 
<button class="tooltip-trigger" data-tooltip="Bottom tooltip here" data-position="bottom">
  Hover me (bottom)
</button>

CSS

.tooltip-trigger {
  position: relative;
  padding: 8px 16px;
  background: white;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  cursor: pointer;
}
 
.tooltip {
  position: absolute;
  background: #1f2937;
  color: white;
  padding: 6px 12px;
  border-radius: 6px;
  font-size: 13px;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s ease;
  z-index: 1000;
}
 
.tooltip.visible {
  opacity: 1;
}
 
/* Arrow */
.tooltip::after {
  content: '';
  position: absolute;
  border: 5px solid transparent;
}
 
.tooltip[data-position="top"] {
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
}
.tooltip[data-position="top"]::after {
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border-top-color: #1f2937;
}
 
.tooltip[data-position="bottom"] {
  top: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
}
.tooltip[data-position="bottom"]::after {
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  border-bottom-color: #1f2937;
}
 
.tooltip[data-position="right"] {
  left: calc(100% + 8px);
  top: 50%;
  transform: translateY(-50%);
}
.tooltip[data-position="right"]::after {
  right: 100%;
  top: 50%;
  transform: translateY(-50%);
  border-right-color: #1f2937;
}
 
.tooltip[data-position="left"] {
  right: calc(100% + 8px);
  top: 50%;
  transform: translateY(-50%);
}
.tooltip[data-position="left"]::after {
  left: 100%;
  top: 50%;
  transform: translateY(-50%);
  border-left-color: #1f2937;
}

JavaScript

class Tooltip {
  constructor() {
    this.triggers = document.querySelectorAll('.tooltip-trigger');
    this.init();
  }
 
  init() {
    this.triggers.forEach((trigger) => {
      const text = trigger.getAttribute('data-tooltip');
      const position = trigger.getAttribute('data-position') || 'top';
 
      // Create tooltip element
      const tooltip = document.createElement('div');
      tooltip.className = 'tooltip';
      tooltip.textContent = text;
      tooltip.setAttribute('role', 'tooltip');
      tooltip.setAttribute('data-position', position);
 
      // Generate unique ID for aria
      const id = 'tooltip-' + Math.random().toString(36).substring(2, 9);
      tooltip.id = id;
      trigger.setAttribute('aria-describedby', id);
 
      trigger.style.position = 'relative';
      trigger.appendChild(tooltip);
 
      // Show/hide
      trigger.addEventListener('mouseenter', () => this.show(tooltip, trigger));
      trigger.addEventListener('mouseleave', () => this.hide(tooltip));
      trigger.addEventListener('focus', () => this.show(tooltip, trigger));
      trigger.addEventListener('blur', () => this.hide(tooltip));
    });
  }
 
  show(tooltip, trigger) {
    tooltip.classList.add('visible');
 
    // Check if tooltip overflows viewport and flip if needed
    requestAnimationFrame(() => {
      const rect = tooltip.getBoundingClientRect();
      const position = tooltip.getAttribute('data-position');
 
      if (position === 'top' && rect.top < 0) {
        tooltip.setAttribute('data-position', 'bottom');
      } else if (position === 'bottom' && rect.bottom > window.innerHeight) {
        tooltip.setAttribute('data-position', 'top');
      } else if (position === 'right' && rect.right > window.innerWidth) {
        tooltip.setAttribute('data-position', 'left');
      } else if (position === 'left' && rect.left < 0) {
        tooltip.setAttribute('data-position', 'right');
      }
    });
  }
 
  hide(tooltip) {
    tooltip.classList.remove('visible');
    // Reset position
    const originalPosition = tooltip.closest('.tooltip-trigger').getAttribute('data-position');
    tooltip.setAttribute('data-position', originalPosition);
  }
}
 
// Initialize
const tooltips = new Tooltip();

Key Interview Points

  • Position flipping: Check getBoundingClientRect() against viewport bounds and swap position
  • aria-describedby: Links the trigger to the tooltip text for screen readers
  • Focus support: Tooltips should also appear on focus, not just hover — keyboard users need them too
  • pointer-events: none: Prevents the tooltip from blocking clicks or triggering mouse events

7. Tabs

Tab panels with proper ARIA roles and keyboard navigation.

HTML

<div class="tabs">
  <div class="tab-list" role="tablist" aria-label="Content sections">
    <button class="tab active" role="tab" aria-selected="true" aria-controls="tab-panel-1" id="tab-1">HTML</button>
    <button class="tab" role="tab" aria-selected="false" aria-controls="tab-panel-2" id="tab-2" tabindex="-1">CSS</button>
    <button class="tab" role="tab" aria-selected="false" aria-controls="tab-panel-3" id="tab-3" tabindex="-1">JavaScript</button>
  </div>
 
  <div class="tab-panel active" role="tabpanel" id="tab-panel-1" aria-labelledby="tab-1">
    <p>HTML (HyperText Markup Language) is the standard markup language for documents designed to be displayed in a web browser.</p>
  </div>
  <div class="tab-panel" role="tabpanel" id="tab-panel-2" aria-labelledby="tab-2" hidden>
    <p>CSS (Cascading Style Sheets) is a style sheet language used for describing the presentation of a document written in a markup language.</p>
  </div>
  <div class="tab-panel" role="tabpanel" id="tab-panel-3" aria-labelledby="tab-3" hidden>
    <p>JavaScript is a programming language that is one of the core technologies of the World Wide Web, alongside HTML and CSS.</p>
  </div>
</div>

CSS

.tabs {
  max-width: 600px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  overflow: hidden;
}
 
.tab-list {
  display: flex;
  background: #f9fafb;
  border-bottom: 1px solid #e5e7eb;
}
 
.tab {
  flex: 1;
  padding: 12px 20px;
  background: transparent;
  border: none;
  border-bottom: 2px solid transparent;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  color: #6b7280;
  transition: color 0.15s ease, border-color 0.15s ease;
}
 
.tab:hover {
  color: #111;
}
 
.tab.active {
  color: #3b82f6;
  border-bottom-color: #3b82f6;
  background: white;
}
 
.tab:focus-visible {
  outline: 2px solid #3b82f6;
  outline-offset: -2px;
}
 
.tab-panel {
  padding: 20px;
}
 
.tab-panel[hidden] {
  display: none;
}

JavaScript

class Tabs {
  constructor(element) {
    this.el = element;
    this.tabs = [...element.querySelectorAll('[role="tab"]')];
    this.panels = [...element.querySelectorAll('[role="tabpanel"]')];
 
    this.bindEvents();
  }
 
  bindEvents() {
    this.tabs.forEach((tab) => {
      tab.addEventListener('click', () => this.activate(tab));
    });
 
    // Keyboard navigation
    this.el.querySelector('[role="tablist"]').addEventListener('keydown', (e) => {
      const index = this.tabs.indexOf(document.activeElement);
      if (index === -1) return;
 
      let target;
      switch (e.key) {
        case 'ArrowRight':
          e.preventDefault();
          target = this.tabs[(index + 1) % this.tabs.length];
          break;
        case 'ArrowLeft':
          e.preventDefault();
          target = this.tabs[(index - 1 + this.tabs.length) % this.tabs.length];
          break;
        case 'Home':
          e.preventDefault();
          target = this.tabs[0];
          break;
        case 'End':
          e.preventDefault();
          target = this.tabs[this.tabs.length - 1];
          break;
      }
 
      if (target) {
        this.activate(target);
        target.focus();
      }
    });
  }
 
  activate(selectedTab) {
    // Deactivate all
    this.tabs.forEach((tab) => {
      tab.classList.remove('active');
      tab.setAttribute('aria-selected', 'false');
      tab.setAttribute('tabindex', '-1');
    });
 
    this.panels.forEach((panel) => {
      panel.classList.remove('active');
      panel.hidden = true;
    });
 
    // Activate selected
    selectedTab.classList.add('active');
    selectedTab.setAttribute('aria-selected', 'true');
    selectedTab.removeAttribute('tabindex');
 
    const panelId = selectedTab.getAttribute('aria-controls');
    const panel = document.getElementById(panelId);
    panel.classList.add('active');
    panel.hidden = false;
  }
}
 
// Initialize
const tabs = new Tabs(document.querySelector('.tabs'));

Key Interview Points

  • Roving tabindex: Only the active tab is in the tab order (tabindex removed). Inactive tabs have tabindex="-1". Arrow keys navigate between them.
  • hidden attribute: Semantically hides inactive panels — better than display: none in CSS because it communicates to assistive tech
  • aria-controls + aria-labelledby: Creates a relationship between each tab and its panel

8. Toast / Notification System

A notification system that stacks, auto-dismisses, and supports different types.

HTML

<div class="toast-container" aria-live="polite" aria-atomic="false"></div>
 
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
  <button onclick="toast.show('File saved successfully', 'success')">Success Toast</button>
  <button onclick="toast.show('Something went wrong', 'error')">Error Toast</button>
  <button onclick="toast.show('Please check your input', 'warning')">Warning Toast</button>
  <button onclick="toast.show('New update available', 'info')">Info Toast</button>
</div>

CSS

.toast-container {
  position: fixed;
  top: 20px;
  right: 20px;
  z-index: 9999;
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-width: 360px;
}
 
.toast {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 16px;
  border-radius: 8px;
  background: white;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  border-left: 4px solid;
  font-size: 14px;
  animation: toast-in 0.3s ease forwards;
  cursor: pointer;
}
 
.toast.removing {
  animation: toast-out 0.3s ease forwards;
}
 
.toast-success { border-left-color: #10b981; }
.toast-error   { border-left-color: #ef4444; }
.toast-warning { border-left-color: #f59e0b; }
.toast-info    { border-left-color: #3b82f6; }
 
.toast-icon {
  font-size: 18px;
  flex-shrink: 0;
}
 
.toast-message {
  flex: 1;
  color: #374151;
}
 
.toast-close {
  background: none;
  border: none;
  font-size: 16px;
  color: #9ca3af;
  cursor: pointer;
  padding: 0 2px;
  flex-shrink: 0;
}
 
.toast-close:hover {
  color: #374151;
}
 
.toast-progress {
  position: absolute;
  bottom: 0;
  left: 4px;
  right: 0;
  height: 3px;
  background: rgba(0, 0, 0, 0.1);
  border-radius: 0 0 8px 0;
}
 
.toast-progress-bar {
  height: 100%;
  border-radius: inherit;
  transition: width linear;
}
 
.toast-success .toast-progress-bar { background: #10b981; }
.toast-error .toast-progress-bar   { background: #ef4444; }
.toast-warning .toast-progress-bar { background: #f59e0b; }
.toast-info .toast-progress-bar    { background: #3b82f6; }
 
@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateX(100%);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}
 
@keyframes toast-out {
  from {
    opacity: 1;
    transform: translateX(0);
  }
  to {
    opacity: 0;
    transform: translateX(100%);
  }
}

JavaScript

class ToastManager {
  constructor(containerSelector = '.toast-container') {
    this.container = document.querySelector(containerSelector);
  }
 
  show(message, type = 'info', duration = 4000) {
    const icons = {
      success: '&#10003;',
      error: '&#10007;',
      warning: '&#9888;',
      info: '&#8505;',
    };
 
    const toast = document.createElement('div');
    toast.className = `toast toast-$\{type\}`;
    toast.style.position = 'relative';
    toast.setAttribute('role', 'alert');
    toast.innerHTML = `
      <span class="toast-icon">${icons[type]}</span>
      <span class="toast-message">${this.escapeHtml(message)}</span>
      <button class="toast-close" aria-label="Dismiss">&times;</button>
      <div class="toast-progress">
        <div class="toast-progress-bar" style="width: 100%"></div>
      </div>
    `;
 
    // Close button
    toast.querySelector('.toast-close').addEventListener('click', () => {
      this.dismiss(toast);
    });
 
    // Click to dismiss
    toast.addEventListener('click', (e) => {
      if (!e.target.closest('.toast-close')) {
        this.dismiss(toast);
      }
    });
 
    this.container.appendChild(toast);
 
    // Animate progress bar
    const progressBar = toast.querySelector('.toast-progress-bar');
    requestAnimationFrame(() => {
      progressBar.style.transitionDuration = duration + 'ms';
      progressBar.style.width = '0%';
    });
 
    // Auto-dismiss
    const timer = setTimeout(() => this.dismiss(toast), duration);
 
    // Pause on hover
    toast.addEventListener('mouseenter', () => {
      clearTimeout(timer);
      progressBar.style.transitionPlayState = 'paused';
    });
 
    toast.addEventListener('mouseleave', () => {
      const remaining = (parseFloat(getComputedStyle(progressBar).width) /
        parseFloat(getComputedStyle(progressBar.parentElement).width)) * duration;
      progressBar.style.transitionPlayState = 'running';
      setTimeout(() => this.dismiss(toast), remaining || 1000);
    });
 
    return toast;
  }
 
  dismiss(toast) {
    if (toast.classList.contains('removing')) return;
    toast.classList.add('removing');
    toast.addEventListener('animationend', () => toast.remove());
  }
 
  escapeHtml(str) {
    const div = document.createElement('div');
    div.textContent = str;
    return div.innerHTML;
  }
}
 
// Initialize
const toast = new ToastManager();

Key Interview Points

  • XSS prevention: escapeHtml() prevents injection through toast messages — interviewers love this detail
  • aria-live="polite": The container announces new toasts to screen readers without interrupting
  • Progress bar animation: CSS transition on width with JS control — shows CSS/JS coordination
  • Pause on hover: Clearing the timeout and pausing the CSS transition shows attention to UX detail
  • Stacking: Flexbox column layout with gap handles multiple toasts naturally

9. Infinite Scroll

Load more content as the user scrolls to the bottom — using IntersectionObserver instead of scroll event listeners.

HTML

<div class="feed">
  <div class="feed-list" id="feed-list"></div>
  <div class="feed-sentinel" id="sentinel"></div>
  <div class="feed-loading" id="loading" hidden>
    <div class="spinner"></div>
    Loading more...
  </div>
  <div class="feed-end" id="end-message" hidden>
    You've reached the end!
  </div>
</div>

CSS

.feed {
  max-width: 600px;
  margin: 0 auto;
}
 
.feed-item {
  padding: 16px 20px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  margin-bottom: 12px;
  background: white;
  animation: fade-in 0.3s ease;
}
 
.feed-item h3 {
  margin: 0 0 4px;
  font-size: 16px;
}
 
.feed-item p {
  margin: 0;
  color: #6b7280;
  font-size: 14px;
}
 
.feed-loading, .feed-end {
  text-align: center;
  padding: 20px;
  color: #6b7280;
  font-size: 14px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}
 
.spinner {
  width: 20px;
  height: 20px;
  border: 2px solid #e5e7eb;
  border-top-color: #3b82f6;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}
 
@keyframes spin {
  to { transform: rotate(360deg); }
}
 
@keyframes fade-in {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}

JavaScript

class InfiniteScroll {
  constructor(options) {
    this.list = document.getElementById(options.listId);
    this.sentinel = document.getElementById(options.sentinelId);
    this.loading = document.getElementById(options.loadingId);
    this.endMessage = document.getElementById(options.endMessageId);
 
    this.page = 1;
    this.pageSize = options.pageSize || 10;
    this.maxItems = options.maxItems || 50;
    this.isLoading = false;
    this.hasMore = true;
 
    this.initObserver();
    this.loadMore(); // Initial load
  }
 
  initObserver() {
    this.observer = new IntersectionObserver(
      (entries) => {
        // When sentinel becomes visible, load more
        if (entries[0].isIntersecting && this.hasMore && !this.isLoading) {
          this.loadMore();
        }
      },
      {
        rootMargin: '200px', // Start loading 200px before reaching bottom
      }
    );
 
    this.observer.observe(this.sentinel);
  }
 
  async loadMore() {
    this.isLoading = true;
    this.loading.hidden = false;
 
    // Simulate API call
    const items = await this.fetchItems(this.page, this.pageSize);
 
    // Render items
    items.forEach((item) => {
      const el = document.createElement('div');
      el.className = 'feed-item';
      el.innerHTML = `<h3>${item.title}</h3><p>${item.body}</p>`;
      this.list.appendChild(el);
    });
 
    this.page++;
    this.isLoading = false;
    this.loading.hidden = true;
 
    // Check if we've loaded everything
    const totalLoaded = this.list.children.length;
    if (totalLoaded >= this.maxItems) {
      this.hasMore = false;
      this.observer.disconnect();
      this.endMessage.hidden = false;
    }
  }
 
  // Mock API — replace with real fetch
  fetchItems(page, pageSize) {
    return new Promise((resolve) => {
      setTimeout(() => {
        const start = (page - 1) * pageSize;
        const items = Array.from({ length: pageSize }, (_, i) => ({
          title: `Post #$\{start + i + 1\}`,
          body: `This is the content for post $\{start + i + 1\}. It contains interesting information.`,
        }));
        resolve(items);
      }, 800);
    });
  }
}
 
// Initialize
const feed = new InfiniteScroll({
  listId: 'feed-list',
  sentinelId: 'sentinel',
  loadingId: 'loading',
  endMessageId: 'end-message',
  pageSize: 10,
  maxItems: 50,
});

Key Interview Points

  • IntersectionObserver vs. scroll events: Observer is far more performant — no throttling/debouncing needed, no layout thrashing
  • Sentinel element: A hidden div at the bottom that triggers loading when it enters the viewport — cleaner than measuring scroll position
  • rootMargin: '200px': Pre-fetches before the user reaches the bottom for a seamless experience
  • Loading guard: isLoading flag prevents duplicate requests during slow network conditions
  • Disconnect on completion: Stop observing when all content is loaded to prevent unnecessary callbacks

10. Drag and Drop Sortable List

Reorderable list using the native HTML5 Drag and Drop API.

HTML

<ul class="sortable-list" id="sortable">
  <li class="sortable-item" draggable="true">
    <span class="drag-handle" aria-hidden="true">&#9776;</span>
    <span>Learn HTML</span>
  </li>
  <li class="sortable-item" draggable="true">
    <span class="drag-handle" aria-hidden="true">&#9776;</span>
    <span>Learn CSS</span>
  </li>
  <li class="sortable-item" draggable="true">
    <span class="drag-handle" aria-hidden="true">&#9776;</span>
    <span>Learn JavaScript</span>
  </li>
  <li class="sortable-item" draggable="true">
    <span class="drag-handle" aria-hidden="true">&#9776;</span>
    <span>Build a project</span>
  </li>
  <li class="sortable-item" draggable="true">
    <span class="drag-handle" aria-hidden="true">&#9776;</span>
    <span>Get a job</span>
  </li>
</ul>

CSS

.sortable-list {
  list-style: none;
  padding: 0;
  max-width: 400px;
  margin: 0 auto;
}
 
.sortable-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  margin-bottom: 8px;
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  cursor: grab;
  user-select: none;
  transition: box-shadow 0.15s ease, transform 0.15s ease;
}
 
.sortable-item:active {
  cursor: grabbing;
}
 
.sortable-item.dragging {
  opacity: 0.4;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
 
.sortable-item.drag-over {
  border-color: #3b82f6;
  background: #eff6ff;
}
 
.drag-handle {
  color: #9ca3af;
  font-size: 14px;
  cursor: grab;
}

JavaScript

class SortableList {
  constructor(listElement) {
    this.list = listElement;
    this.items = [...listElement.querySelectorAll('.sortable-item')];
    this.draggedItem = null;
 
    this.bindEvents();
  }
 
  bindEvents() {
    this.list.addEventListener('dragstart', (e) => {
      const item = e.target.closest('.sortable-item');
      if (!item) return;
 
      this.draggedItem = item;
      item.classList.add('dragging');
 
      // Required for Firefox
      e.dataTransfer.effectAllowed = 'move';
      e.dataTransfer.setData('text/plain', '');
 
      // Slight delay so the dragging class applies after the drag image is captured
      requestAnimationFrame(() => {
        item.classList.add('dragging');
      });
    });
 
    this.list.addEventListener('dragover', (e) => {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
 
      const target = e.target.closest('.sortable-item');
      if (!target || target === this.draggedItem) return;
 
      // Determine if we should insert before or after
      const rect = target.getBoundingClientRect();
      const midY = rect.top + rect.height / 2;
 
      // Remove all drag-over states
      this.list.querySelectorAll('.drag-over').forEach((el) => {
        el.classList.remove('drag-over');
      });
 
      target.classList.add('drag-over');
 
      if (e.clientY < midY) {
        this.list.insertBefore(this.draggedItem, target);
      } else {
        this.list.insertBefore(this.draggedItem, target.nextSibling);
      }
    });
 
    this.list.addEventListener('dragend', (e) => {
      if (this.draggedItem) {
        this.draggedItem.classList.remove('dragging');
      }
 
      this.list.querySelectorAll('.drag-over').forEach((el) => {
        el.classList.remove('drag-over');
      });
 
      this.draggedItem = null;
 
      // Get new order
      const newOrder = [...this.list.querySelectorAll('.sortable-item')].map(
        (item) => item.querySelector('span:last-child').textContent
      );
      console.log('New order:', newOrder);
    });
 
    this.list.addEventListener('drop', (e) => {
      e.preventDefault();
    });
  }
}
 
// Initialize
const sortable = new SortableList(document.getElementById('sortable'));

Key Interview Points

  • midY calculation: Determines whether to insert before or after the target element — gives a natural reordering feel
  • Event delegation: All events attached to the parent list, not individual items — performant and handles dynamic items
  • Firefox quirk: e.dataTransfer.setData('text/plain', '') is required for drag to work in Firefox
  • requestAnimationFrame: Ensures the visual dragging state applies after the browser captures the drag ghost image
  • user-select: none: Prevents text selection during drag

Interview Tips

Before You Start Coding

  1. Clarify requirements. Ask: "Should this support keyboard navigation? Should it be accessible? Should it handle edge cases like empty states?"
  2. Talk through your approach. Explain: HTML structure first, then CSS layout, then JS behavior.
  3. Mention accessibility upfront. Even if the interviewer doesn't ask, mentioning ARIA roles and keyboard support shows seniority.

While Coding

  1. Start with the HTML structure. Get the semantic markup right first — it's the foundation.
  2. Use CSS for everything you can. Transitions, hover states, show/hide — don't reach for JS when CSS can do it.
  3. Use event delegation. Attaching listeners to parent elements is more performant and handles dynamic content.
  4. Name things clearly. handleTab, goToSlide, dismiss — the interviewer is reading your code in real time.

Common Follow-Up Questions

QuestionWhat They're Testing
"How would you make this accessible?"ARIA roles, keyboard navigation, screen reader support
"What if there are 10,000 items?"Virtualization, pagination, IntersectionObserver
"How would you test this?"Unit tests for logic, integration tests for DOM, accessibility audits
"How would you make this a reusable component?"Constructor options, events API, no global state
"What about mobile?"Touch events, responsive design, swipe gestures

You might also like