
Loopem is a dependency-free JavaScript picker library for creating draggable selection UIs such as color swatches, date wheels, product options, and photo carousels. You can use a line, arc, vertical wheel, Cover Flow arrangement, fan, stack, or custom geometry as tracks.
Use the createPicker() API on an empty container, and Loopem renders the visible area of the item list, snaps movement to an item, handles pointer, wheel, and keyboard input, and leaves item dimensions and styling to your CSS.
Features
- Virtualized slots for long item arrays.
- Line and arc layouts built into the core package.
- Wheel, Cover Flow, fan, and stack presets.
- Custom geometry through layout functions.
- Drag, flick, mouse wheel, and keyboard navigation.
- Horizontal and vertical picker tracks.
- Automatic and manual selection modes.
- Looping and clamped navigation.
- Autoplay with reduced-motion handling.
- Scroll-linked picker movement.
- Structural CSS with application-defined item styling.
See it in action
How To Use It
Installation
Install the package via NPM
npm install loopem
Direct Browser Module
import { createPicker } from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';
Basic Usage
Create an empty container, an item array, and a renderItem function.
HTML
<div id="colorPicker" aria-label="Choose a color"></div>
CSS
Loopem controls item positions. Your stylesheet sets the picker height and the dimensions of the elements created by renderItem.
Keep spacing larger than the item width when a line or arc should display gaps between neighboring items.
#colorPicker {
height: 180px;
}
.color-chip {
width: 54px;
height: 54px;
border: 0;
border-radius: 14px;
cursor: pointer;
}
JavaScript
import { createPicker } from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm';
const colors = [
{ name: 'Ocean', hex: '#2563eb' },
{ name: 'Forest', hex: '#16a34a' },
{ name: 'Coral', hex: '#f97316' },
{ name: 'Plum', hex: '#9333ea' },
{ name: 'Slate', hex: '#475569' }
];
const picker = createPicker(document.getElementById('colorPicker'), {
items: colors,
spacing: 72,
fade: 70,
renderItem(color) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'color-chip';
button.style.backgroundColor = color.hex;
button.setAttribute('aria-label', color.name);
return button;
}
});
picker.on('select', ({ index, item }) => {
console.log(index, item);
}, { immediate: true });
Active And Selected Items
Loopem keeps the centered item and the committed choice as two states.
The active item changes whenever another item crosses the center. The selected item represents the value the application has committed. This distinction is useful for media pickers where captions should follow movement while a large image should load only after selection.
const activeIndex = picker.getActiveIndex(); const activeItem = picker.getActiveItem(); const selectedIndex = picker.getSelectedIndex(); const selectedItem = picker.getSelectedItem();
Arc Layout
Set layout to 'arc' for a curved track. radius controls curvature. Larger values produce a flatter arc.
bend accepts up or down on horizontal tracks and left or right on vertical tracks.
const picker = createPicker(document.getElementById('colorPicker'), {
items: colors,
renderItem,
layout: 'arc',
spacing: 76,
radius: 520,
bend: 'down',
minScale: 0.72,
minOpacity: 0.35,
falloff: 4
});
Layout Presets
Four preset functions are available from loopem/layouts.
wheel()creates a vertical drum-style picker.coverflow()arranges cards in 3D perspective.fan()spreads items around a shared pivot.stack()arranges items as a vertical card deck.
The preset options spread directly into the createPicker() configuration object.
import { createPicker } from 'loopem';
import { coverflow } from 'loopem/layouts';
const gallery = createPicker(document.getElementById('gallery'), {
items: photos,
renderItem,
...coverflow()
});
Custom Layouts
A custom layout function receives the signed distance of an item from the center. The centered item receives 0, items after it receive positive values, and items before it receive negative values.
Return placement values such as x, y, z, rotate, scale, and opacity. Continuous calculations around zero keep movement visually consistent during dragging.
const waveLayout = (distance) => ({
x: distance * 84,
y: Math.sin(distance * 0.75) * 34,
z: -Math.abs(distance),
rotate: Math.cos(distance * 0.75) * 8,
scale: 1 - Math.min(Math.abs(distance) / 5, 1) * 0.3,
opacity: 1 - Math.min(Math.abs(distance) / 5, 1) * 0.65
});
const picker = createPicker(document.getElementById('wavePicker'), {
items,
renderItem,
layout: waveLayout,
spacing: 84
});
Browse Before Committing A Selection
The default select: 'auto' mode commits the active item after the picker settles.
Set select to 'manual' when dragging should browse the list while the existing selection stays unchanged. A click, Enter, Space, or select() call commits a value.
const picker = createPicker(document.getElementById('photoPicker'), {
items: photos,
renderItem,
select: 'manual'
});
picker.on('change', ({ item }) => {
updateCaption(item);
});
picker.on('select', ({ item }) => {
loadLargePhoto(item);
});
Programmatic Selection
select(index) commits the new selected value immediately. The track then moves toward that item when animation is enabled.
Use scrollTo(index) when the track should move while the committed selection stays unchanged.
picker.select(6);
picker.select(2, {
animate: false
});
picker.scrollTo(8);
picker.scrollTo(4, {
animate: false
});
Autoplay
Set autoplay to a dwell interval in milliseconds. Autoplay pauses during direct interaction, while the pointer is over the picker, and while the document is hidden.
Autoplay does not start when prefers-reduced-motion: reduce is active. A picker using loop: 'clamp' stops advancing after it reaches the final item.
const picker = createPicker(document.getElementById('showcase'), {
items: slides,
renderItem,
autoplay: 2400,
loop: 'wrap'
});
Scroll-Linked Pickers
linkToScroll() maps the surrounding section’s movement through the viewport to a continuous picker offset.
The helper turns direct picker input off and returns an object with destroy(). Destroying the scroll link does not turn picker input back on. Call setInteractive(true) when manual interaction should return later.
Note taht the scrolling section needs enough height to produce measurable viewport progress.
import { createPicker, linkToScroll } from 'loopem';
const section = document.getElementById('productStages');
const picker = createPicker(section.querySelector('.stage-picker'), {
items: stages,
renderItem,
loop: 'clamp'
});
const scrollLink = linkToScroll(picker, section);
// Later:
scrollLink.destroy();
picker.setInteractive(true);
Configuration Options
items(array, required): Contains the data represented by the picker.renderItem(function): Creates the element for an item. Omit it when slot contents will be managed through theslotsevent.layout('line' | 'arc' | function, default'line'): Defines item geometry.axis('horizontal' | 'vertical', default'horizontal'): Sets track direction.spacing(number, default72): Sets the pixel distance between item centers and the drag distance for one item step.radius(number, default360): Sets arc curvature. Larger values flatten the curve.bend('up' | 'down' | 'left' | 'right'): Sets the side toward which an arc bends. The valid direction depends on the axis.tilt(boolean, defaultfalse): Rotates arc items along the curve.minScale(number, default1): Sets the scale reached by items at the end of the falloff range.minOpacity(number, default1): Sets the opacity reached by items at the end of the falloff range.falloff(number, default1): Sets how many item positions the scale and opacity ramps use.fade(number, default0): Sets the edge mask width in pixels.perspective(number, default1200): Sets the distance to the vanishing point in pixels.0disables perspective.stiffness(number, default12): Sets how quickly normal movement settles, measured in radians per second.friction(number, default0.02): Sets the fraction of flick velocity left after one second.loop('wrap' | 'clamp', default'wrap'): Sets continuous wrapping or fixed ends.select('auto' | 'manual', default'auto'): Sets automatic settling selection or explicit selection.initialIndex(number, default0): Sets the starting item.autoplay(number): Sets the dwell interval in milliseconds. Autoplay is off when this option is omitted.overscan(number, default2): Keeps extra items mounted past the visible window.interactive(boolean, defaulttrue): Controls drag, wheel, pointer, and keyboard input.
API Methods
// Return the centered item index.
picker.getActiveIndex();
// Return the centered item.
picker.getActiveItem();
// Return the committed item index.
picker.getSelectedIndex();
// Return the committed item.
picker.getSelectedItem();
// Return the currently mounted virtualized slots.
picker.getSlots();
// Return the item count.
picker.getCount();
// Move the track while keeping the current committed selection.
picker.scrollTo(5);
picker.scrollTo(5, { animate: false });
// Move forward by one item.
picker.next();
// Move backward by one item.
picker.previous();
// Commit an item and move it to the center.
picker.select(5);
picker.select(5, { animate: false });
// Set a continuous position in item units.
picker.seek(2.5);
// Enable or disable direct input.
picker.setInteractive(false);
picker.setInteractive(true);
// Replace the item array.
picker.update(nextItems);
// Resume configured autoplay.
picker.play();
// Pause configured autoplay.
picker.pause();
// Subscribe to an event and receive an unsubscribe function.
const unsubscribe = picker.on('select', ({ index, item }) => {
console.log(index, item);
});
unsubscribe();
// Remove listeners, mounted slots, and running animation work.
picker.destroy();
Events
Pass { immediate: true } when a listener should receive the current state as soon as it is registered.
// Fires whenever another item crosses the center.
picker.on('change', ({ index, item }) => {
console.log('Active:', index, item);
});
// Fires when motion ends.
picker.on('settle', ({ index, item }) => {
console.log('Settled:', index, item);
});
// Fires when an item becomes the committed selection.
picker.on('select', ({ index, item }) => {
console.log('Selected:', index, item);
});
// Fires for every motion frame.
picker.on('move', ({ offset }) => {
console.log('Offset:', offset);
});
// Fires when the mounted virtualized slot set changes.
picker.on('slots', ({ slots }) => {
console.log('Slots:', slots);
});
Styling And Customization
Loopem injects structural CSS when the first picker mounts. Your own CSS controls item dimensions, color, typography, borders, radii, shadows, and other visual details.
Style the element returned by renderItem for normal item design. .loopem-slot receives transform updates during picker movement.
The useful DOM and CSS hooks are:
.loopem: Picker container..loopem-track: Zero-sized positioning origin in the center..loopem-slot: Mounted virtualized item slot..loopem-moving: Present while picker motion is active..loopem-interactive: Present while the picker accepts direct input.data-loopem-key: Virtual item key.data-loopem-centered: Marks the centered slot.aria-selected="true": Marks the committed selection on an interactive picker.--loopem-opacity: Overrides calculated slot opacity from application CSS.
Centered And Selected Styles
The active item and committed selection can use different visual states. This works especially well with manual selection.
.color-chip {
width: 56px;
height: 56px;
border: 0;
border-radius: 16px;
}
.loopem-slot[data-loopem-centered] .color-chip {
box-shadow: 0 0 0 2px #94a3b8;
}
.loopem-slot[aria-selected="true"] .color-chip {
box-shadow: 0 0 0 3px #0f172a;
}
.loopem-slot[aria-selected="true"] {
--loopem-opacity: 1;
}
Edge Fading
minOpacity and falloff change each item’s opacity according to its distance from the center. fade applies a mask at the container edges.
Use item opacity for the main dissolve effect and a small edge mask when items still reach the clipped boundary.
const picker = createPicker(document.getElementById('gallery'), {
items: photos,
renderItem,
minOpacity: 0,
falloff: 5,
fade: 80
});
Disable Item Input During Motion
.loopem-moving exists only while the track is moving. It is useful when controls inside an item should ignore pointer input during a drag or flick.
.loopem-moving .color-chip {
pointer-events: none;
}
Keyboard Controls And Accessibility
Interactive pickers use role="listbox" on the container and role="option" on mounted slots. The centered slot is tracked through aria-activedescendant, while aria-selected identifies the committed item.
- Left and Right Arrow move one item on horizontal tracks.
- Up and Down Arrow move one item on vertical tracks.
- Page Up and Page Down jump five items.
- Home moves to the first item.
- End moves to the last item.
- Enter chooses the centered item.
- Space chooses the centered item.
- Autoplay stays off under
prefers-reduced-motion. - Horizontal tracks preserve vertical touch scrolling.
- Vertical tracks preserve horizontal touch movement.
interactive: falseremoves the listbox roles and keyboard behavior.
Alternatives
- Embla Carousel: Draggable & Touch-friendly Carousel In Vanilla JavaScript
- Touch-Ready, High-Performance Vanilla JS Slider – Pagiflow
- Stacked Card Carousel Slider With Vanilla JavaScript – MVP Carousel
- Minimal Draggable Swipeable Image Carousel/Slider In Vanilla JavaScript







