Turnstone
React customisable autocomplete component with typeahead and grouped results from multiple APIs.
Install / Use
/learn @tomsouthall/TurnstoneREADME
Turnstone is a highly customisable, easy-to-use autocomplete search component for React.
Turnstone In Action
View Demos | Demo #1 | Demo #2 | Demo #3 (Basic)
Play with Turnstone at CodeSandbox

Features
- Lightweight React search box component
- Group search results from multiple APIs or other data sources with customisable headings
- Specify the maximum number of listbox options as well as weighted display ratios for each group
- Completely customise listbox options with your own React component. Add images, icons, additional sub-options, differing visual treatments by group or index and much more...
- Display typeahead autosuggest text beneath entered text
- Easily styled with various CSS methods including CSS Modules and Tailwind CSS
- Search input can be easily styled to attach to top of screen at mobile screen sizes with customisable cancel/back button to exit
- Multiple callbacks including:
onSelect,onChange,onTab,onEnterand more... - Built in WAI-ARIA accessibility
- Keyboard highlighting and selection using arrow, Tab and Enter keys
- Automated caching to reduce data fetches
- Debounce text entry to reduce data fetches
- Optional Clear button (customisable)
- Customisable placeholder text
- Add more functionality with plugins
- and much more...
Installation & Usage
$ npm install --save turnstone
Usage
Barebones unstyled example
import React from 'react'
import Turnstone from 'turnstone'
const App = () => {
const listbox = {
data: ['Peach', 'Pear', 'Pineapple', 'Plum', 'Pomegranate', 'Prune']
}
return (
<Turnstone listbox={listbox} />
)
}
Styled example with grouped results from two API sources
import React, { useState } from 'react'
import Turnstone from 'turnstone'
const styles = {
input,
inputFocus,
query,
typeahead,
cancelButton,
clearButton,
listbox,
groupHeading,
item,
highlightedItem
}
const maxItems = 10
const listbox = [
{
id: 'cities',
name: 'Cities',
ratio: 8,
displayField: 'name',
data: (query) =>
fetch(`/api/cities?q=${encodeURIComponent(query)}&limit=${maxItems}`)
.then(response => response.json()),
searchType: 'startswith'
},
{
id: 'airports',
name: 'Airports',
ratio: 2,
displayField: 'name',
data: (query) =>
fetch(`/api/airports?q=${encodeURIComponent(query)}&limit=${maxItems}`)
.then(response => response.json()),
searchType: 'contains'
}
]
export default function Example() {
return (
<Turnstone
cancelButton={true}
debounceWait={250}
id="search"
listbox={listbox}
listboxIsImmutable={true}
matchText={true}
maxItems={maxItems}
name="search"
noItemsMessage="We found no places that match your search"
placeholder="Enter a city or airport"
styles={styles}
typeahead={true}
/>
)
}
Example Markup
This is an example of markup produced by the component, in this case with the
text New entered into the search box.
<div class="container" role="combobox" aria-expanded="true" aria-owns="search-listbox" aria-haspopup="listbox">
<input type="text" id="search" name="search" class="input query" style="position:relative;z-index:1;background-color:transparent" placeholder="Enter a city or airport" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" aria-autocomplete="both" aria-controls="search-listbox">
<input type="text" class="input typeahead" style="position:absolute;z-index:0;top:0;left:0" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" tabindex="-1" readonly="" aria-hidden="true">
<button class="clearButton" tabindex="-1" aria-label="Clear contents" style="z-index: 2;">×</button>
<button class="cancelButton" tabindex="-1" aria-label="Cancel" style="z-index: 3;">Cancel</button>
<div id="search-listbox" class="listbox" role="listbox" style="position: absolute; z-index: 4;">
<div class="groupHeading">Cities</div>
<div class="highlightedItem" role="option" aria-selected="true" aria-label="New York City, New York, United States"><strong>New</strong> York City, New York, United States</div>
<div class="item" role="option" aria-selected="false" aria-label="New South Memphis, Tennessee, United States"><strong>New</strong> South Memphis, Tennessee, United States</div>
<div class="item" role="option" aria-selected="false" aria-label="New Kingston, Jamaica"><strong>New</strong> Kingston, Jamaica</div>
<div class="item" role="option" aria-selected="false" aria-label="Newcastle, South Africa"><strong>New</strong>castle, South Africa</div>
<div class="item" role="option" aria-selected="false" aria-label="New Orleans, Louisiana, United States"><strong>New</strong> Orleans, Louisiana, United States</div>
<div class="item" role="option" aria-selected="false" aria-label="New Delhi, India"><strong>New</strong> Delhi, India</div>
<div class="item" role="option" aria-selected="false" aria-label="Newcastle, Australia"><strong>New</strong>castle, Australia</div>
<div class="item" role="option" aria-selected="false" aria-label="Newport, Wales"><strong>New</strong>port, Wales</div>
<div class="groupHeading">Airports</div>
<div class="item" role="option" aria-selected="false" aria-label="John F Kennedy Intl (JFK), New York, United States"item>John F Kennedy Intl (JFK), <strong>New</strong> York, United States</div>
<div class="item" role="option" aria-selected="false" aria-label="Newark Liberty Intl (EWR), Newark, United States"><strong>New</strong>ark Liberty Intl (EWR), <strong>New</strong>ark, United States</div>
</div>
</div>
API
Props
The following props can be supplied to the <Turnstone> component:
autoFocus
- Type:
boolean - Default:
false - If
truethe search input automatically receives focus - Note: If
defaultListboxprop is supplied, settingautoFocusto true causes the default listbox to be automatically opened.
cancelButton
- Type:
boolean - Default:
false - If
truea cancel button is rendered. The cancel button is displayed only when the search box receives focus. It is particularly useful for mobile screen sizes where a "back" button is required in order to exit the focused state of the search box.
cancelButtonAriaLabel
- Type:
string - Default:
"Cancel" - The value of the
aria-labelattribute on the cancel button element.
clearButton
- Type:
boolean - Default:
false - If
truea clear button is rendered whenever the user has entered at least one character into the search box. - Clicking the clear button has the same effect as pressing the Esc key while entering text into the search box. The contents of the searchbox are cleared and focus is retained.
- Suggested styling for the clear button is to position it absolutely overlaying the right of the search box, for example:
.clearButton { display: block; width: 2rem; right: 0px; top: 0px; bottom: 0px; position: absolute; color: #a8a8a8; cursor: pointer; border: none; background: transparent; padding:0; }
clearButtonAriaLabel
- Type:
string - Default:
"Clear contents" - The value of the
aria-labelattribute on the clear button element.
debounceWait
- Type:
number - Default:
250 - The wait time in milliseconds after the user finishes typing before the search query is sent to the fetch function.
- This reduces the number of API calls made by the fetch function
- Set to
0if you want no wait at all (e.g. if your listbox data is not fetched asynchronously)
defaultListbox
- Type:
arrayorobjectorfunction - Default:
undefined - The default listbox is displayed when the search box has focus and is empty.
- Supply an array if you wish multiple groups of items to appear in the default listbox. Groups can
be drawn from multiple sources. For example:
[ { name: 'Recent Searches', displayField: 'name', data: () => Promise.resolve(JSON.parse(localStorage.getItem('recent')) || []), id: 'recent', ratio: 1 }, { name: 'Popular Cities', displayField: 'name', data: [ { name: 'Paris, France', coords: '48.86425, 2.29416' }, { name: 'Rome, Italy', coords: '41.89205, 12.49209' }, { name: 'Orlando, Florida, United States', coords: '28.53781, -81.38592' }, { name: 'London, England', coords: '51.50420, -0.12426' }, { name: 'Barcelona, Spain', coords: '41.40629, 2.17555' }, { name: 'New Orleans, Louisiana, United States', coords: '29.95465,-90.07507' }, { name: 'Chicago, Illinois, United States', coords: '41.85003,-87.65005' }, { name: 'Manchester, England', coords: '53.48095,-2.23743' } ], id: 'popular', ratio: 1 } ] - Supply an object if you wish an ungrouped set of items to appear in the default listbox. For example:
{
Related Skills
bluebubbles
343.3kUse when you need to send or manage iMessages via BlueBubbles (recommended iMessage integration). Calls go through the generic message tool with channel="bluebubbles".
node-connect
343.3kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
slack
343.3kUse when you need to control Slack from OpenClaw via the slack tool, including reacting to messages or pinning/unpinning items in Slack channels or DMs.
frontend-design
92.1kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
