Navigation Feedback

Flash a landed target after TOC, footnote, search, or custom navigation jumps.

Loading...
Files
components/demo.tsx
'use client';

import * as React from 'react';

import { Plate, usePlateEditor } from 'platejs/react';

import { EditorKit } from '@/components/editor/editor-kit';
import { Editor, EditorContainer } from '@/components/ui/editor';

import { createValue } from './values/demo-values';

export default function Demo({ id }: { id: string }) {
  const editor = usePlateEditor({
    plugins: EditorKit,
    value: createValue(id),
  });

  return (
    <Plate editor={editor}>
      <EditorContainer variant="demo">
        <Editor />
      </EditorContainer>
    </Plate>
  );
}

Features

  • Briefly highlight the landed node after a navigation jump.
  • Replace any previous flash deterministically — no stacked timers, no doubled animations.
  • Expose transforms for flash-only and full select-focus-scroll-flash flows.
  • Inject data-nav-* attributes so any render can style the active target.

Navigation Feedback is a small core plugin for "you landed here" UX. It doesn't own navigation itself — it flashes the landed node so the reader can see where the editor just moved. Reach for it in TOC jumps, footnote navigation, search results, and custom outline surfaces.

Usage

Core Plugin

NavigationFeedbackPlugin is part of Plate core and is included by createPlateEditor automatically. You don't need to add it to the plugins array for the defaults to work.

Configure Duration

The default flash lasts 1.6 seconds. If you want it tighter or longer, use the top-level navigationFeedback editor option:

import { createPlateEditor } from 'platejs/react';
 
const editor = createPlateEditor({
  navigationFeedback: {
    duration: 1200,
  },
});
  • navigationFeedback.duration: Default flash duration in milliseconds. Default: 1600.

Disable the plugin entirely if you don't want any landed-target flash:

const editor = createPlateEditor({
  navigationFeedback: false,
});

Flash a Target

When another action already handled scroll, focus, and selection, and you only want the visual confirmation, call editor.tf.navigation.flashTarget:

editor.tf.navigation.flashTarget({
  target: {
    path: [12],
    type: 'node',
  },
});

The call returns false when the path doesn't resolve to a node, so you can branch on stale targets without throwing.

Override the duration or variant per call when this jump deserves its own look:

editor.tf.navigation.flashTarget({
  duration: 1500,
  target: {
    path: [12],
    type: 'node',
  },
  variant: 'mention',
});

The variant string is written straight to data-nav-highlight, so you can key distinct CSS animations off it — 'navigated', 'mention', 'found', whatever you need.

Most navigation actions should do four things at once: move the selection, focus the editor, scroll the target into view, and flash the landed node. editor.tf.navigation.navigate does all four, and each step is independent — skip any of them with the flags below.

Here's how the footnote plugin jumps from a reference to its definition:

editor.tf.navigation.navigate({
  focus: true,
  scroll: true,
  scrollTarget: point,
  select: {
    anchor: { offset: 0, path: firstTextPath },
    focus: { offset: 0, path: firstTextPath },
  },
  target: {
    path: definition[1],
    type: 'node',
  },
});

Skip the flash when the jump should be silent:

editor.tf.navigation.navigate({
  flash: false,
  select: point,
  target: { path: [12], type: 'node' },
});

Or tune the flash per call:

editor.tf.navigation.navigate({
  flash: { duration: 1200, variant: 'mention' },
  select: point,
  target: { path: [12], type: 'node' },
});

If you don't pass scrollTarget, Plate picks a scroll point in this order: select.focus, select.anchor, select (when it's a Point), then editor.api.start(target.path).

Style the Landed Target

Whenever a target is active, the plugin injects transient attributes onto that node's DOM and a CSS variable with the current duration:

AttributeValue
data-nav-target"true" on the active node.
data-nav-highlightCurrent variant (e.g. "navigated").
data-nav-cycle"0" or "1" — alternates per flash so CSS animations restart cleanly.
data-nav-pulseMonotonic pulse counter. Useful for debugging repeat triggers.
--plate-nav-feedback-durationInline CSS variable set to ${duration}ms.

Style them anywhere your editor styles live:

.slate-editor [data-nav-highlight] {
  border-radius: 0.375rem;
}
 
.slate-editor [data-nav-highlight][data-nav-cycle='0'] {
  animation: plate-nav-highlight-a var(--plate-nav-feedback-duration, 900ms)
    ease-out;
}
 
.slate-editor [data-nav-highlight][data-nav-cycle='1'] {
  animation: plate-nav-highlight-b var(--plate-nav-feedback-duration, 900ms)
    ease-out;
}

Why two animations? Flashing the same node twice in a row on the same keyframe name wouldn't restart the animation. The plugin alternates data-nav-cycle between 0 and 1 so adjacent flashes run different animation names and the browser replays cleanly.

Highlight Custom Renders

Attribute injection fires through the plugin's nodeProps inject, so any standard PlateElement render that spreads its attributes onto the root DOM node picks up data-nav-* for free.

For atoms, inline voids, or components that need the highlight state inside JSX (e.g. on a nested button), read it with useNavigationHighlight:

import { useNavigationHighlight, usePath } from 'platejs/react';
 
export function FootnoteReferenceElement(props) {
  const path = usePath();
  const highlight = useNavigationHighlight(path);
 
  return (
    <PlateElement
      {...props}
      attributes={{
        ...props.attributes,
        'data-nav-cycle': highlight ? String(highlight.cycle) : undefined,
        'data-nav-highlight': highlight?.variant,
        'data-nav-pulse': highlight ? String(highlight.pulse) : undefined,
        'data-nav-target': highlight ? 'true' : undefined,
        style: {
          ...props.attributes.style,
          '--plate-nav-feedback-duration': highlight
            ? `${highlight.duration}ms`
            : undefined,
        },
      }}
    >
      {props.children}
    </PlateElement>
  );
}

The hook returns null when this node isn't the active target and the full target ({ cycle, duration, path, pulse, type, variant }) when it is. Pass a Path, a TElement, or a TText — paths go through, nodes get resolved via editor.api.findPath.

Done. You now have a deterministic flash on every jump and the wiring to style or extend it.

Plugins

Core plugin for transient "landed target" feedback after successful navigation.

    Default flash duration in milliseconds.

    • Default: 1600

API

api.navigation.activeTarget

Get the current flashed target, or null when none is active. The returned target carries a resolved path, so later edits that shift the target keep the highlight on the right node.

Returns

    Active target { cycle, duration, path, pulse, type, variant }, or null.

api.navigation.clear

Clear the current feedback target immediately. Safe to call when nothing is active.

api.navigation.isTarget

Check whether a given path is the current flashed target.

Parameters

    Path to compare against the active target.

Returns

    true when there is an active target and its path equals path.

Transforms

tf.navigation.flashTarget

Flash a target node without changing selection, focus, or scroll. Replaces any active flash on the same editor.

Parameters

    Node target to flash.

    Override the default duration for this call.

    Highlight variant stored in data-nav-highlight.

    • Default: navigated

Returns

    false when the path doesn't resolve to a node, true otherwise.

tf.navigation.navigate

Select, focus, scroll, and flash a target in one call. Each step is independent — skip any of them with the flags below.

    Node target to navigate to.

    Point (collapsed) or range to apply before scrolling and flashing.

    Focus the editor after selection.

    • Default: true

    Scroll the resolved point into view.

    • Default: true

    Explicit point to scroll into view. Falls back to select.focus, select.anchor, select, then editor.api.start(target.path).

    Per-call flash config. Pass false to navigate without flashing.

    false when the path doesn't resolve to a node, true otherwise.

tf.navigation.clear

Clear the current flashed target immediately. Same effect as api.navigation.clear.

Hooks

useNavigationHighlight

Subscribe a custom render to the active navigation target. Returns the target metadata when the given path/node matches, null otherwise.

Parameters

    Path to compare, or a node the hook resolves via editor.api.findPath.

Returns

    Active target metadata when this node is the current flashed target, null otherwise.