ui-021

Spotlight Search Modal

£10 or the whole Vault, £99

A spotlight search modal for apps and docs: opened from the search field or the / key, with grouped results, a preview and full keyboard control.

intermediate
6 more details
morphingdialog-animationfuzzy-searchkeyboard-navigationstaggercommand-palette

This element reads better at your own screen size than in the frame below:

Updated

About this component

A spotlight search for a whole site or app. The search field morphs into the panel: the panel starts as a copy of the field's measured box and tweens width, height and position to its own, so the two read as one object rather than a field that fades out and a dialog that fades in. Behind it the page goes under a dimmed backdrop and is scroll-locked at the offset the reader left it at.

Read the full effect overview

Inside, results are grouped under Articles, Collections and People with a live count on every heading, the matched run of characters is marked in each label, and a group with nothing left under it drops out. Beside the list a preview column fills from the active row's own data attributes, so the reader sees what they are about to open before they commit to it. An empty field shows recent searches instead of a blank panel. Arrow keys move the selection with a rail sliding down the active row, Enter flashes it in the accent, Escape closes and hands focus back to the field.

One HTML file, one stylesheet, one script, vanilla JavaScript and core GSAP, no framework and no plugins. Two themes, light and dark, switched with one data-variant attribute.

Spotlight Search Modal - UI component preview

What's included

10 items
  • Launcher morphs into the panel with a measured box tween, never a scale
  • Results grouped under Articles, Collections and People with a live count per heading
  • Preview column driven by each row's own data-preview attributes, and optional
  • Matched characters wrapped in mark, and empty groups hide themselves
  • Recent searches when the field is empty
  • Arrow keys wrap through the visible rows, Enter flashes the selection, Escape closes
  • Opens on / anywhere on the page, and on Cmd+K or Ctrl+K
  • combobox / listbox / option roles with aria-activedescendant
  • Focus trapped while open, returned on close, and the page behind scroll-locked
  • Two themes, light and dark, on one set of custom properties

Perfect for

4 use cases
  • Global search in dashboards, admin portals and SaaS apps
  • Documentation and knowledge bases that need one field across articles, collections and authors
  • Directories where people and content share a result list
  • Any header search box that currently opens a separate results page

How it works

4 sections

The morph

Opening measures the launcher and the panel with getBoundingClientRect, then tweens the panel from the launcher's width, height and centre offset to its own. Width and height are tweened rather than scaled, so the text inside is never squashed, and both are cleared when the tween lands so the panel goes back to being laid out by CSS. The panel carries no min-height, because a floor would stop the box shrinking back to the button on close.

Grouping and marking

Each keystroke walks the rows, tests the query against the row's data-label and toggles the hidden attribute, which the stylesheet restates as display:none because the row rule sets display:flex. Every group then counts what survived, writes the number into its heading badge and hides itself when the count is zero. Labels are re-rendered from data-label with the matched run wrapped in a mark element, so repeated filtering never compounds earlier markup.

The preview column

Moving the selection reads the active row's data-preview attributes and writes them into the column beside the list, so a row rendered from your own data needs no script change. The facts list is built with createElement and textContent rather than a template string, and it hides itself when a row carries no facts rather than leaving a rule floating under empty space. Drop the aside altogether and the result list simply fills the panel.

Keyboard model

Focus never leaves the input. Arrow keys move an index over the visible rows only, wrapping at both ends, and the input carries aria-activedescendant so assistive technology follows the same row the rail is under. A document-level listener opens the panel on the / key when nothing is being typed into and on Cmd+K or Ctrl+K anywhere, and Escape closes the panel and returns focus to the launcher. The same listener traps Tab inside the panel, and the scroll lock covers the root element as well as the body, because the body's overflow only reaches the viewport while the root's own overflow is visible.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Spotlight Search Modal opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source with an onReady guard

  • The launcher morphs into the panel with a measured box tween, never a scale

  • Results grouped under Articles, Collections and People, with a live count per heading

  • A preview column that fills from the active row's own data-preview-* attributes

  • Matched characters wrapped in <mark>; empty groups hide themselves

  • Recent searches when the field is empty

  • Arrow keys, Enter to open the selection, Escape to close

  • Opens on / anywhere on the page, and on Cmd+K or Ctrl+K

  • The page behind is scroll-locked and held at the offset the reader left it at

  • combobox / listbox / option roles with aria-activedescendant

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add to your <body>:

Copy the launcher button and the whole .spot-overlay block from index.html. Each result is a plain list item carrying its own text:

Code snippet omitted: it ships with the download.

data-label is the source of truth for both the filter and the re-rendered label, so keep it in step with the visible text. data-kind="recent" marks a row as a recent search: those rows show only when the field is empty, and choosing one fills the field instead of navigating.

The data-preview-* attributes drive the preview column and are all optional: data-preview-title falls back to data-label, and data-preview-facts takes Label=Value pairs separated by semicolons. Delete the <aside class="spot-preview"> altogether and the result list simply fills the panel; nothing in the script needs changing.

Add or remove <section class="spot-group"> blocks freely; the script reads them on load and counts them on every keystroke.

3. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

The demo toolbar strip, the .stage-* ground and the short article behind the overlay are demo furniture, not part of the component. Leave them behind: the component is the launcher plus the .spot-overlay block.

Keyboard & Accessibility

Key What it does
/ Opens the spotlight, unless you are already typing in a field
Cmd+K / Ctrl+K Toggles the spotlight from anywhere
Up / Down Moves the selection through the visible rows, wrapping at both ends
Enter Opens the selection; on a recent search it puts the term back in the field
Escape Closes, focus returns to the launcher
Tab / Shift+Tab Cycles within the panel while open
  • The launcher is a real button with aria-haspopup="dialog" and aria-expanded; the panel is role="dialog" with aria-modal="true".
  • The field is a role="combobox" with aria-controls, aria-expanded and aria-autocomplete="list"; the list is a role="listbox" of role="option" rows.
  • The active row is published through aria-activedescendant and aria-selected, so focus can stay in the input where typing belongs. The preview column is aria-live="polite", so what the selection is about to open is announced.
  • Focus is trapped in the panel while it is open and returned to the launcher on close, with preventScroll so a launcher above the fold cannot drag the restored scroll position with it.
  • Scroll lock covers <html> as well as <body>: the body's overflow only reaches the viewport while the root element's own overflow is visible, so locking the body alone does nothing on a page that sets any overflow of its own. The offset is captured on lock, held with a fixed body so the view does not jump, and restored on unlock.
  • Without JavaScript a <noscript> rule drops the panel into the flow under the launcher, so every result stays readable; only the filtering and the morph are lost.
  • prefers-reduced-motion: reduce sets every duration to zero, so the panel appears and rows move instantly rather than not at all.

How It Works

The morph. Opening measures the launcher and the panel with getBoundingClientRect, then tweens the panel from the launcher's width, height and centre offset to its own. Width and height are tweened rather than scaled, so the text inside is never squashed, and both are cleared with clearProps when the tween lands so the panel goes back to being laid out by CSS. The panel carries no min-height, because a floor would stop the box shrinking back to the field on close.

Grouping and marking. Each keystroke walks the rows, tests the query against data-label and toggles the hidden attribute, which the stylesheet restates as display: none because the row rule sets display: flex. Every group then counts what survived, writes the number into its heading badge and hides itself when the count is zero. Labels are re-rendered from data-label with the matched run wrapped in a <mark>, so repeated filtering never compounds earlier markup.

The preview column. Moving the selection reads the active row's data-preview-* attributes and writes them into the column beside the list, so a row rendered from your own data needs no script change. The facts list is built with createElement and textContent rather than a template string, and it hides itself when a row carries no facts (a recent search) rather than leaving a rule floating under empty space.

Keyboard model. Focus never leaves the input. Arrow keys move an index over the visible rows only, wrapping at both ends, and the input carries aria-activedescendant so assistive technology follows the row the rail is under. One document-level listener handles /, Cmd+K, Escape and the Tab trap, rather than binding a new set per open.

Themes

The component ships in two themes, light and dark, which are one design at two sets of token values. Set the attribute on <body> in the demo, or on the component's root in your own page:

Code snippet omitted: it ships with the download.

The demo's toolbar toggle and the ?variant=light URL parameter only set that attribute. Nothing in the script reads the theme name.

Every colour, radius and shadow both the component and the demo ground use is a custom property in the body[data-variant="light"] and body[data-variant="dark"] blocks at the top of assets/style.css. To fit your own brand, re-value that one list:

  • Ground and ink: --ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3, --line, --line-strong
  • Accent: --accent, --accent-fg, --accent-wash, and --row-rail, which is what the accent actually moves
  • Panel: --panel-bg, --panel-border, --panel-radius, --panel-shadow, --backdrop, --backdrop-blur
  • Rows and preview: --row-fg, --row-active-bg, --row-active-fg, --mark-bg, --mark-color, --meta-fg, --preview-bg, --preview-border

--accent, --accent-fg, --row-active-bg and --row-active-fg are written as literal colours rather than var() references, because the script reads them with getComputedStyle when it flashes a chosen row.

Customisation

What Where
Morph speed and feel duration: dur(0.46) and power3.out in openSpotlight
Backdrop strength --backdrop and --backdrop-blur in the theme blocks
Row stagger stagger: dur(0.02) in filter()
Selection nudge the gsap.fromTo(row, { x: -5 } …) tween in setActive
Match rule label.indexOf(query) in filter() and renderLabel
Panel width, list height, preview width width on .spot-panel, max-height on .spot-results, width on .spot-preview
Launch keys the document keydown listener at the foot of the script
Colours, radii, shadows the body[data-variant] blocks in assets/style.css

To drive results from data, render the same <li class="spot-row" data-label="…"> rows before the script runs; nothing in assets/script.js needs to change. For a remote source, replace the body of filter() with your request, rebuild the rows and call setActive(0).

Requirements

  • GSAP 3.12+ (core only)
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.