Vibe is an opinionated Vue 3 mixed-media feed and viewer engine for large datasets.
It is built for apps that need a virtualized desktop feed, fullscreen browsing, library-owned paging, removals, retries, and asset-failure handling without stitching together a masonry grid, lightbox, and feed state by hand.
Vibe is built for applications that browse large mixed-media collections and need feed state to stay coherent as items load, fail, disappear, and move between desktop and fullscreen surfaces.
It owns paging, fullscreen browsing, removals, retries, and asset-failure handling as part of one viewer model so those behaviors stay consistent under real feed pressure.
Vibe is hardened in production through Atlas as a downstream consumer.
Use Vibe when your app needs:
- a large mixed-media feed of images, video, audio, and document-like items
- library-owned paging driven by opaque cursors
- a desktop masonry surface that opens into fullscreen
- fullscreen-first behavior on mobile and tablet
- built-in remove, restore, undo, retry, and asset-failure handling
- a small customization surface through slots instead of a headless rendering API
Install the package and import the bundled stylesheet once:
npm i @wyxos/vibeimport '@wyxos/vibe/style.css'Tailwind scanning is not required for the package UI.
The default export is the plugin:
import { createApp } from 'vue'
import App from './App.vue'
import Vibe from '@wyxos/vibe'
import '@wyxos/vibe/style.css'
createApp(App)
.use(Vibe)
.mount('#app')That registers the global component as VibeLayout.
If you prefer local registration, import VibeLayout directly:
<script setup lang="ts">
import {
VibeLayout,
type VibeResolveParams,
type VibeResolveResult,
} from '@wyxos/vibe'
import '@wyxos/vibe/style.css'
async function resolve({ cursor, pageSize }: VibeResolveParams): Promise<VibeResolveResult> {
const response = await fetch(`/api/feed?cursor=${cursor ?? ''}&pageSize=${pageSize}`)
return await response.json()
}
</script>
<template>
<VibeLayout :resolve="resolve" />
</template>Vibe keeps the item contract deliberately small:
type VibeViewerItem = {
id: string
type: 'image' | 'video' | 'audio' | 'other'
title?: string
url: string
preview?: {
url: string
width?: number
height?: number
mediaType?: 'image' | 'video'
}
healthCheck?: {
url: string
kind?: 'playback'
} | null
width?: number
height?: number
[key: string]: unknown
}Notes:
- grid mode prefers
preview.url, then falls back tourl - fullscreen mode uses
url - grid layout prefers preview dimensions, then root dimensions, then a square fallback tile
otherstays intentionally broad so the consuming app can layer its own file subtypes and icon logic on tophealthCheckis optional and lets the app provide an explicit asset probe, for example when preview URLs are not enough to classify playback health reliably
Use resolve when you want Vibe to own the paging loop:
type VibeResolveParams = {
cursor: string | null
pageSize: number
signal?: AbortSignal
}
type VibeResolveResult = {
items: VibeViewerItem[]
nextPage: string | null
previousPage?: string | null
}Vibe owns:
- loaded items
- active index
- next-page prefetch
- optional previous-page loading
- duplicate cursor protection
- initial retry state
- removal-aware feed navigation
Vibe has one built-in paging model:
- if a resolve returns fewer visible items than
pageSize, Vibe enters a fill loop - it waits
fillDelayMs, thenfillDelayMs + fillDelayStepMs, and so on for each chained request - it keeps accumulating results until the collected count reaches
pageSizeor there is no further cursor - then it commits that batch into the layout once
- before advancing at the bottom or top, Vibe checks whether the current boundary page is underfilled after local removals
- if it is, Vibe reloads that same cursor in place first and reconciles only that page's content in the grid
- if that boundary refresh comes back empty, the same boundary attempt can continue to the next or previous cursor
- when the trailing edge is exhausted, another bottom-edge attempt reloads the trailing cursor so newly available pages can be discovered
Example:
<VibeLayout
:resolve="resolve"
:page-size="25"
:fill-delay-ms="2000"
:fill-delay-step-ms="1000"
:show-end-badge="false"
:show-status-badges="false"
/>Use initialState when the app already knows a restored slice of the feed and wants Vibe to hydrate from that snapshot immediately:
<VibeLayout
:resolve="resolve"
:initial-state="{
cursor: 'page-10',
items: restoredItems,
nextCursor: 'page-11',
previousCursor: 'page-9',
activeIndex: 4,
}"
/>Notes:
resolveis optional if you only need a seeded snapshot- when
resolveis present, Vibe continues paging from the seeded cursors
- desktop starts in the masonry list surface
- clicking a grid tile opens fullscreen
Escapereturns from fullscreen to the list on desktop- mobile and tablet always force fullscreen
- grid uses preview assets and in-view loading
- fullscreen uses the original asset and shows a spinner until ready
Common behavior props:
surfaceMode: lets the parent drive the desktop list/fullscreen surface explicitlyemptyStateMode: controls whether empty states render inline, as a badge, or stay hiddenpaginationDetail: lets the parent attach app-owned cursor or page context to the built-in status UIshowDominantImageTone,loopFullscreenVideo,showEndBadge, andshowStatusBadges: tune the built-in fullscreen and status behavior without taking over the surfaces
VibeLayout exposes a small set of app-owned surfaces instead of a headless render API:
empty-statefullscreen-asidefullscreen-header-actionsfullscreen-overlayfullscreen-statusgrid-footergrid-item-overlaygrid-statusitem-icon
Example:
<VibeLayout :resolve="resolve">
<template #item-icon="{ item, icon }">
<component :is="item.type === 'other' ? MyCustomIcon : icon" />
</template>
<template #grid-item-overlay="{ item, hovered }">
<div v-if="hovered" class="absolute inset-x-0 bottom-0 p-3">
<div class="pointer-events-auto bg-black/45 px-3 py-2 backdrop-blur">
Like {{ item.title }}
</div>
</div>
</template>
<template #grid-footer>
<div class="bg-black/55 px-4 py-3 backdrop-blur">
Custom footer UI
</div>
</template>
</VibeLayout>Get a component ref and use VibeHandle:
import { ref } from 'vue'
import type { VibeHandle } from '@wyxos/vibe'
const vibe = ref<VibeHandle | null>(null)<VibeLayout ref="vibe" :resolve="resolve" />Available methods:
lockPageLoading()unlockPageLoading()loadNext()loadPrevious()retry()cancel()remove(ids)restore(ids)undo()getRemovedIds()clearRemoved()
Available state:
type VibeStatus = {
activeIndex: number
currentCursor: string | null
errorMessage: string | null
fillCollectedCount: number | null
fillCursor: string | null
fillDelayRemainingMs: number | null
fillTargetCount: number | null
hasNextPage: boolean
hasPreviousPage: boolean
itemCount: number
loadState: 'failed' | 'loaded' | 'loading'
nextBoundaryLoadProgress: number
nextCursor: string | null
pageLoadingLocked: boolean
phase: 'failed' | 'filling' | 'idle' | 'initializing' | 'loading' | 'refreshing'
previousBoundaryLoadProgress: number
previousCursor: string | null
removedCount: number
removedIds: readonly string[]
surfaceMode: 'fullscreen' | 'list'
}Example:
vibe.value?.lockPageLoading()
vibe.value?.remove(['item-2', 'item-5'])
vibe.value?.undo()
vibe.value?.unlockPageLoading()
console.log(vibe.value?.status.itemCount)
console.log(vibe.value?.status.pageLoadingLocked)
console.log(vibe.value?.status.nextBoundaryLoadProgress)
console.log(vibe.value?.status.removedIds)VibeLayout emits:
update:activeIndexupdate:surfaceModeasset-loadsasset-errors
asset-loads is micro-batched and emits an array payload:
type VibeAssetLoadEvent = {
item: VibeViewerItem
occurrenceKey: string
url: string
surface: 'grid' | 'fullscreen'
}asset-errors is micro-batched and emits an array payload:
type VibeAssetErrorEvent = {
item: VibeViewerItem
occurrenceKey: string
url: string
kind: 'generic' | 'not-found'
surface: 'grid' | 'fullscreen'
}Example:
<script setup lang="ts">
import type { VibeAssetErrorEvent, VibeAssetLoadEvent } from '@wyxos/vibe'
function onAssetLoads(loads: VibeAssetLoadEvent[]) {
console.log(loads)
}
function onAssetErrors(errors: VibeAssetErrorEvent[]) {
console.log(errors)
}
</script>
<template>
<VibeLayout
:resolve="resolve"
@asset-loads="onAssetLoads"
@asset-errors="onAssetErrors"
/>
</template>Notes:
- successful loads that happen close together are batched into one event
- grid load events usually report
preview.urlwhen a preview exists - fullscreen load events report
item.url - multiple failures that happen close together are batched into one event
- identical failures are deduped inside the same batch
- if the same item fails again later, it can emit again in a later batch
- built-in Vibe error surfaces allow retrying
genericfailures, but notnot-found
Run:
npm run devRoutes:
/- default feed surface/documentation- in-app documentation/demo/feed-behavior- unified fill, retry, boundary refresh, removals, and paging-lock demo/debug/fake-server- fake-server inspection route
npm run check
npm run build
npm run build:lib
npm run build:types
npm run test:unit
npm run test:e2elib/is generated output- source of truth is under
src/ - the package ships compiled CSS at
@wyxos/vibe/style.css
