diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -59,6 +59,32 @@ const MAX_CACHED_PAGES: usize = 24;
 /// `MAX_CACHED_PAGES` so raster tuning never silently retunes text memory.
 const MAX_CACHED_TEXT_PAGES: usize = 24;
 
+/// The user-space rectangle to flash for a destination, within the page's crop box `crop`
+/// (see [`DocView::flash_dest`]). A point with only one coordinate flashes along the other
+/// axis from the crop box's edge; non-finite input marks nothing.
+fn dest_flash_rect(dest: DestView, crop: [f32; 4]) -> Option<[f32; 4]> {
+    const LINE: f32 = 24.0;
+    const WIDTH: f32 = 160.0;
+    let [cx0, cy0, cx1, cy1] = crop;
+    let rect = match dest {
+        DestView::FitR { rect } => rect,
+        DestView::Xyz { left, top, .. } if left.is_some() || top.is_some() => point_box(left, top, crop, WIDTH, LINE),
+        DestView::FitH { top: Some(top) } => point_box(None, Some(top), crop, WIDTH, LINE),
+        DestView::FitV { left: Some(left) } => point_box(Some(left), None, crop, WIDTH, LINE),
+        _ => return None,
+    };
+    let [x0, y0, x1, y1] = rect;
+    let (x0, x1, y0, y1) = (x0.min(x1).max(cx0), x0.max(x1).min(cx1), y0.min(y1).max(cy0), y0.max(y1).min(cy1));
+    (rect.iter().all(|v| v.is_finite()) && x1 > x0 && y1 > y0).then_some([x0, y0, x1, y1])
+}
+
+/// A box hanging down and right from (`left`, `top`); a missing coordinate takes the crop box's
+/// left or top edge.
+fn point_box(left: Option<f32>, top: Option<f32>, crop: [f32; 4], width: f32, height: f32) -> [f32; 4] {
+    let (x, y) = (left.unwrap_or(crop[0]), top.unwrap_or(crop[3]));
+    [x, y - height, x + width, y]
+}
+
 fn rgba_bytes(width: usize, height: usize) -> usize {
     width.saturating_mul(height).saturating_mul(4)
 }
@@ -1561,6 +1587,18 @@ impl DocView {
         }
     }
 
+    /// Flash the place an internal link lands on, so it can be found on a dense page. The
+    /// rectangle comes from the destination: `/FitR` names one, and a point (`/XYZ`, `/FitH`,
+    /// `/FitV` with a coordinate) gets a box a line high at that point. `/Fit` and a destination
+    /// with no position name no place to mark. (Acrobat's own extent is not documented, so the
+    /// box is an approximation.)
+    pub fn flash_dest(&mut self, page: usize, dest: DestView, info: &DocInfo) {
+        // Cleared first: a flash left from an earlier link would mark the wrong place.
+        self.flash = None;
+        let Some(p) = info.pages.get(page) else { return };
+        self.flash = dest_flash_rect(dest, p.crop).map(|r| (page, r, 0.0));
+    }
+
     /// Next page (`true`) or previous page. In two-page view this moves a whole spread: the other
     /// page of the spread is already on screen, so stepping to it wouldn't move the view (#70).
     pub fn step_page(&mut self, forward: bool) {
@@ -4551,7 +4589,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
         }
     }
     match clicked_link {
-        Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info),
+        Some(LinkTarget::Page(p, dest)) => {
+            view.go_to_dest(p, dest, info);
+            view.flash_dest(p, dest, info);
+        }
         Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link),
         Some(LinkTarget::SetLayers { changes, preserve_rb }) => set_layers(app, index, &changes, preserve_rb),
         Some(LinkTarget::RemoteGoTo { file, destination, .. }) => remote_open = Some((view.id, file, destination)),
@@ -7885,6 +7926,41 @@ trailer << /Root 1 0 R >>
         assert_eq!((v.current, v.goto_point), (1, None));
     }
 
+    #[test]
+    fn a_followed_link_flashes_where_its_destination_lands() {
+        let page = |w: f32, h: f32| pdfcraft_render::PageInfo { width: w, height: h, label: String::new(), crop: [0.0, 0.0, w, h], rotation: 0 };
+        let info = pdfcraft_render::DocInfo { pages: vec![page(300.0, 400.0), page(300.0, 400.0)], ..Default::default() };
+        let mut v = view(2, PageLayout::Single, true);
+        // /FitR names the rectangle itself, kept inside the page.
+        v.flash_dest(1, DestView::FitR { rect: [10.0, 20.0, 110.0, 70.0] }, &info);
+        assert_eq!(v.flash, Some((1, [10.0, 20.0, 110.0, 70.0], 0.0)));
+        v.flash_dest(1, DestView::FitR { rect: [-50.0, 20.0, 110.0, 500.0] }, &info);
+        assert_eq!(v.flash, Some((1, [0.0, 20.0, 110.0, 400.0], 0.0)));
+        // A point gets a box hanging from it; a missing coordinate falls back to the page edge.
+        v.flash_dest(0, DestView::Xyz { left: Some(50.0), top: Some(300.0), zoom: None }, &info);
+        assert_eq!(v.flash, Some((0, [50.0, 276.0, 210.0, 300.0], 0.0)));
+        v.flash_dest(0, DestView::FitH { top: Some(300.0) }, &info);
+        assert_eq!(v.flash, Some((0, [0.0, 276.0, 160.0, 300.0], 0.0)));
+        // An unknown page clears an earlier flash instead of leaving it on the wrong place.
+        v.flash_dest(0, DestView::FitH { top: Some(300.0) }, &info);
+        assert!(v.flash.is_some());
+        v.flash_dest(9, DestView::FitH { top: Some(300.0) }, &info);
+        assert_eq!(v.flash, None);
+        // Nothing to mark: no position, a whole-page fit, an unknown page, or junk numbers.
+        for (page, dest) in [
+            (0, DestView::Top),
+            (0, DestView::Fit),
+            (0, DestView::Xyz { left: None, top: None, zoom: Some(2.0) }),
+            (0, DestView::FitH { top: None }),
+            (9, DestView::FitH { top: Some(300.0) }),
+            (0, DestView::FitR { rect: [f32::NAN, 0.0, 10.0, 10.0] }),
+            (0, DestView::FitR { rect: [500.0, 500.0, 600.0, 600.0] }),
+        ] {
+            v.flash_dest(page, dest, &info);
+            assert_eq!(v.flash, None, "{dest:?} on page {page}");
+        }
+    }
+
     #[test]
     fn page_text_failure_preserves_valid_search_match_without_jitter() {
         let mut v = view(3, PageLayout::Single, true);
