-
-
{placeholder}
+
-
+
>
diff --git a/apps/docs/features/ui/LoadingBeam.tsx b/apps/docs/features/ui/LoadingBeam.tsx
new file mode 100644
index 00000000000..e9dd73faf67
--- /dev/null
+++ b/apps/docs/features/ui/LoadingBeam.tsx
@@ -0,0 +1,33 @@
+'use client'
+
+import { useState } from 'react'
+import { cn } from 'ui'
+
+export interface LoadingBeamProps {
+ isActive: boolean
+ direction?: 'forward' | 'backward'
+ className?: string
+}
+
+export const LoadingBeam = ({ isActive, direction = 'forward', className }: LoadingBeamProps) => {
+ const [run, setRun] = useState(0)
+ const [runDirection, setRunDirection] = useState(direction)
+ const [wasActive, setWasActive] = useState(isActive)
+ if (isActive !== wasActive) {
+ setWasActive(isActive)
+ if (isActive) {
+ setRun((current) => current + 1)
+ setRunDirection(direction)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/docs/styles/code-block.css b/apps/docs/styles/code-block.css
index 762e9e829c3..66bc752d432 100644
--- a/apps/docs/styles/code-block.css
+++ b/apps/docs/styles/code-block.css
@@ -39,6 +39,7 @@
}
[data-theme='light'],
.light {
+ --shadow-codeblock: 0 0 #0000;
--code-token-keyword: #5f2fc4;
--code-foreground: oklch(from var(--foreground-light) l c h / 1);
--code-token-constant: #15593b;
diff --git a/apps/docs/styles/globals.css b/apps/docs/styles/globals.css
index 2a39bcb51c0..82d4d622f81 100644
--- a/apps/docs/styles/globals.css
+++ b/apps/docs/styles/globals.css
@@ -1,6 +1,8 @@
@import 'config/tailwind.config.css';
@import './code-block.css';
@import './reference.css';
+@import './loading-beam.css';
+@import './scroll-fade.css';
@source '../app/**/*.{ts,tsx,mdx}';
@source '../components/**/*.tsx';
diff --git a/apps/docs/styles/loading-beam.css b/apps/docs/styles/loading-beam.css
new file mode 100644
index 00000000000..c82758df11f
--- /dev/null
+++ b/apps/docs/styles/loading-beam.css
@@ -0,0 +1,80 @@
+@property --loading-beam-x {
+ syntax: '
';
+ initial-value: 0.5;
+ inherits: true;
+}
+
+@keyframes loading-beam-sweep {
+ 0%,
+ 100% {
+ --loading-beam-x: 0;
+ }
+ 50% {
+ --loading-beam-x: 1;
+ }
+}
+
+@layer components {
+ .loading-beam {
+ --loading-beam-color: var(--foreground-default);
+ --loading-beam-half: 100px;
+ --loading-beam-center: calc(
+ var(--loading-beam-half) + var(--loading-beam-x) * (100% - var(--loading-beam-half) * 2)
+ );
+ position: absolute;
+ inset: auto 0 0;
+ height: 16px;
+ overflow: hidden;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 150ms ease-out;
+ animation: loading-beam-sweep 2s ease-in-out -0.25s infinite paused;
+ }
+
+ :is([data-theme='light'], .light) .loading-beam {
+ --loading-beam-color: var(--primary);
+ }
+
+ .loading-beam[data-direction='backward'] {
+ animation-delay: -1.25s;
+ }
+
+ .loading-beam[data-active] {
+ opacity: 1;
+ transition-duration: 250ms;
+ animation-play-state: running;
+ }
+
+ .loading-beam::before,
+ .loading-beam::after {
+ content: '';
+ position: absolute;
+ }
+
+ .loading-beam::before {
+ inset: 0 0 -8px;
+ filter: blur(4px);
+ background: radial-gradient(
+ 120px 12px at var(--loading-beam-center) calc(100% - 8px),
+ color-mix(in oklch, var(--loading-beam-color) 3.2%, transparent),
+ transparent
+ );
+ }
+
+ .loading-beam::after {
+ inset: auto 0 0;
+ height: 1px;
+ background: radial-gradient(
+ var(--loading-beam-half) 4px at var(--loading-beam-center) 100%,
+ color-mix(in oklch, var(--loading-beam-color) 44.5%, transparent),
+ color-mix(in oklch, var(--loading-beam-color) 16%, transparent) 45%,
+ transparent
+ );
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .loading-beam {
+ animation: none;
+ }
+ }
+}
diff --git a/apps/docs/styles/scroll-fade.css b/apps/docs/styles/scroll-fade.css
new file mode 100644
index 00000000000..e7525d8dd83
--- /dev/null
+++ b/apps/docs/styles/scroll-fade.css
@@ -0,0 +1,32 @@
+@property --scroll-fade-size {
+ syntax: '';
+ initial-value: 0px;
+ inherits: false;
+}
+
+@keyframes scroll-fade-bottom {
+ 0%,
+ 85% {
+ --scroll-fade-size: 24px;
+ }
+ 100% {
+ --scroll-fade-size: 0px;
+ }
+}
+
+@layer components {
+ @supports (animation-timeline: scroll()) {
+ .scroll-fade-bottom {
+ mask-image: linear-gradient(
+ to bottom,
+ #000 calc(100% - var(--scroll-fade-size)),
+ rgb(0 0 0 / 0.8) calc(100% - var(--scroll-fade-size) * 0.75),
+ rgb(0 0 0 / 0.5) calc(100% - var(--scroll-fade-size) * 0.5),
+ rgb(0 0 0 / 0.2) calc(100% - var(--scroll-fade-size) * 0.25),
+ transparent 100%
+ );
+ animation: scroll-fade-bottom linear both;
+ animation-timeline: scroll(self);
+ }
+ }
+}
diff --git a/packages/common/hooks/index.ts b/packages/common/hooks/index.ts
index 23bbecba6a7..93b1984adde 100644
--- a/packages/common/hooks/index.ts
+++ b/packages/common/hooks/index.ts
@@ -11,6 +11,7 @@ export * from './useDocsSearchV2'
export * from './useDragToClose'
export * from './useEffectEvent'
export * from './useIsomorphicLayoutEffect'
+export * from './useMountEffect'
export * from './useOnChange'
export * from './useParams'
export * from './useSearchParamsShallow'
diff --git a/packages/common/hooks/useDocsSearchV2.ts b/packages/common/hooks/useDocsSearchV2.ts
index 57ecd61aeeb..26af620d52d 100644
--- a/packages/common/hooks/useDocsSearchV2.ts
+++ b/packages/common/hooks/useDocsSearchV2.ts
@@ -3,6 +3,8 @@
import { compact, debounce } from 'lodash'
import { useCallback, useMemo, useReducer, useRef } from 'react'
+import { useMountEffect } from './useMountEffect'
+
// This app's own base path, set only for apps deployed under a path prefix (docs' is '/docs').
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
// Public URL of the docs deployment, which hosts the search API routes.
@@ -21,7 +23,7 @@ interface DocsSearchV2Result {
type SearchState =
| { status: 'initial'; key: number }
- | { status: 'loading'; key: number; staleResults: DocsSearchV2Result[] }
+ | { status: 'loading'; key: number; staleResults: DocsSearchV2Result[]; staleQuery: string }
| { status: 'results'; key: number; results: DocsSearchV2Result[]; query: string }
| { status: 'noResults'; key: number; query: string }
| { status: 'error'; key: number; message: string }
@@ -74,6 +76,8 @@ function reducer(state: SearchState, action: Action): SearchState {
key: action.key,
staleResults:
'results' in state ? state.results : 'staleResults' in state ? state.staleResults : [],
+ // keep highlighted query while loading
+ staleQuery: 'query' in state ? state.query : 'staleQuery' in state ? state.staleQuery : '',
}
case 'reset':
return { status: 'initial', key: action.key }
@@ -122,6 +126,9 @@ const useDocsSearchV2 = () => {
debouncedSearch.cancel()
}, [debouncedSearch])
+ // the dialog unmounts on close, so cancel any pending search instead of fetching after it's gone
+ useMountEffect(() => debounceCancel)
+
const resetSearch = useCallback(() => {
debounceCancel()
key.current += 1
diff --git a/packages/common/hooks/useMountEffect.ts b/packages/common/hooks/useMountEffect.ts
new file mode 100644
index 00000000000..8bf47840b61
--- /dev/null
+++ b/packages/common/hooks/useMountEffect.ts
@@ -0,0 +1,12 @@
+'use client'
+
+import { useEffect, type EffectCallback } from 'react'
+
+/**
+ * runs an effect once on mount, with its cleanup on unmount, the escape hatch for one-time
+ * sync with an external system and derive state or use event handlers everywhere else
+ */
+export function useMountEffect(effect: EffectCallback): void {
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(effect, [])
+}