diff --git a/app/components/Package/List.vue b/app/components/Package/List.vue
index bf9e1c2ee..4e8f75536 100644
--- a/app/components/Package/List.vue
+++ b/app/components/Package/List.vue
@@ -112,6 +112,12 @@ watch(
{ immediate: true },
)
+// Tracks how many items came from the last new-search batch.
+// Items at index < newSearchBatchSize are from the new search → no animation.
+// Items at index >= newSearchBatchSize were loaded via scroll → animate with stagger.
+// Using an index threshold avoids any timing dependency on nextTick / virtual list paint.
+const newSearchBatchSize = shallowRef(Infinity)
+
// Reset scroll state when results change significantly (new search)
watch(
() => props.results,
@@ -123,6 +129,7 @@ watch(
(oldResults.length > 0 && newResults[0]?.package.name !== oldResults[0]?.package.name)
) {
hasScrolledToInitial.value = false
+ newSearchBatchSize.value = newResults.length
}
},
)
@@ -172,9 +179,16 @@ defineExpose({
:show-publisher="showPublisher"
:index="index"
:search-query="searchQuery"
- class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
+ :class="
+ index >= newSearchBatchSize &&
+ 'motion-safe:animate-fade-in motion-safe:animate-fill-both'
+ "
+ :style="
+ index >= newSearchBatchSize
+ ? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` }
+ : {}
+ "
:filters="filters"
- :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
@click-keyword="emit('clickKeyword', $event)"
/>
@@ -224,8 +238,15 @@ defineExpose({
:show-publisher="showPublisher"
:index="index"
:search-query="searchQuery"
- class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
- :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
+ :class="
+ index >= newSearchBatchSize &&
+ 'motion-safe:animate-fade-in motion-safe:animate-fill-both'
+ "
+ :style="
+ index >= newSearchBatchSize
+ ? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` }
+ : {}
+ "
:filters="filters"
@click-keyword="emit('clickKeyword', $event)"
/>
diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts
index 77367a049..45bb47848 100644
--- a/app/composables/useGlobalSearch.ts
+++ b/app/composables/useGlobalSearch.ts
@@ -4,6 +4,8 @@ import { debounce } from 'perfect-debounce'
// Pages that have their own local filter using ?q
const pagesWithLocalFilter = new Set(['~username', 'org'])
+const SEARCH_DEBOUNCE_MS = 100
+
export function useGlobalSearch(place: 'header' | 'content' = 'content') {
const { settings } = useSettings()
const { searchProvider } = useSearchProvider()
@@ -27,10 +29,14 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
// Syncs instantly when instantSearch is on, but only on Enter press when off
const committedSearchQuery = useState