Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/components/ReadingTimeIndicator/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React, { useEffect, useState, useCallback, useRef } from "react";
import styles from "./styles.module.css";

const MIN_REMAINING_MINUTES = 1;

interface Props {
totalReadTime: number; // in minutes
authorCardRef: React.RefObject<HTMLElement | null>;
}

export default function ReadingTimeIndicator({
totalReadTime,
authorCardRef,
}: Props): JSX.Element | null {
const [visible, setVisible] = useState(false);
const [remainingTime, setRemainingTime] = useState(totalReadTime);
const rafRef = useRef<number | null>(null);

const computeState = useCallback(() => {
const scrollY = window.scrollY;
const winHeight = window.innerHeight;
const docHeight = document.documentElement.scrollHeight;

const maxScroll = docHeight - winHeight;
const pageScrollPercent = maxScroll > 0 ? (scrollY / maxScroll) * 100 : 0;

// Hide when the author card has entered the viewport,
// or fall back to hiding at 90% page scroll when there is no author card
let authorCardReached = false;
if (authorCardRef.current) {
const rect = authorCardRef.current.getBoundingClientRect();
authorCardReached = rect.top < winHeight;
} else {
authorCardReached = pageScrollPercent >= 90;
}

const shouldBeVisible = pageScrollPercent >= 15 && !authorCardReached;
setVisible(shouldBeVisible);

// Calculate remaining time proportional to how far through the content the
// user has scrolled. Use author card position when available; otherwise use
// overall page scroll percentage as fallback.
if (shouldBeVisible) {
let readProgress = 0;
if (authorCardRef.current) {
const authorCardAbsTop =
authorCardRef.current.getBoundingClientRect().top + scrollY;
readProgress =
authorCardAbsTop > 0
? Math.max(0, Math.min(1, scrollY / authorCardAbsTop))
: 0;
} else {
readProgress = Math.max(0, Math.min(1, pageScrollPercent / 90));
}
const remaining = Math.max(
MIN_REMAINING_MINUTES,
Math.ceil(totalReadTime * (1 - readProgress))
);
setRemainingTime(remaining);
}
}, [totalReadTime, authorCardRef]);

const handleScroll = useCallback(() => {
// Throttle via requestAnimationFrame to avoid expensive layout reads on
// every scroll event.
if (rafRef.current !== null) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
computeState();
});
}, [computeState]);

useEffect(() => {
if (totalReadTime < MIN_REMAINING_MINUTES) return;
window.addEventListener("scroll", handleScroll, { passive: true });
computeState();
return () => {
window.removeEventListener("scroll", handleScroll);
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [handleScroll, computeState, totalReadTime]);

if (totalReadTime < MIN_REMAINING_MINUTES || !visible) return null;

const minLabel = remainingTime === MIN_REMAINING_MINUTES ? "min" : "mins";

return (
<div
className={styles.container}
role="status"
aria-label={`Estimated reading time: ${remainingTime} ${minLabel} remaining`}
aria-live="polite"
aria-atomic="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={styles.icon}
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>{remainingTime} {minLabel} remaining</span>
</div>
);
}
49 changes: 49 additions & 0 deletions src/components/ReadingTimeIndicator/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
.container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
align-items: center;
gap: 0.45rem;
padding: 8px 14px;
border-radius: 999px;
background: #0d9488;
color: #fff;
font-size: 0.875rem;
font-weight: 600;
box-shadow: 0 4px 16px rgba(13, 148, 136, 0.35);
animation: fadeInUp 0.25s ease-out;
pointer-events: none;
user-select: none;
}

[data-theme="dark"] .container {
background: #0f766e;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}

.icon {
flex-shrink: 0;
opacity: 0.9;
}

@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

@media (max-width: 640px) {
.container {
bottom: 16px;
right: 16px;
font-size: 0.8rem;
padding: 6px 12px;
}
}
12 changes: 10 additions & 2 deletions src/theme/BlogPostItem/Footer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react";
import React, { useRef } from "react";
import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { useBlogPost } from "@docusaurus/plugin-content-blog/client";
Expand All @@ -8,6 +8,7 @@ import type { WrapperProps } from "@docusaurus/types";
import GiscusComments from "../../../components/giscus";
import SocialShare from "../../../components/SocialShare";
import { getAuthorProfile } from "../../../utils/authors";
import ReadingTimeIndicator from "../../../components/ReadingTimeIndicator";

import styles from "./styles.module.css";

Expand Down Expand Up @@ -52,6 +53,7 @@ export default function BlogPostItemFooterWrapper(props: Props): JSX.Element {
const { siteConfig } = useDocusaurusContext();
const { metadata, isBlogPostPage } = useBlogPost();
const primaryAuthor = metadata.authors?.[0];
const authorCardRef = useRef<HTMLElement | null>(null);

const profile = primaryAuthor?.key
? getAuthorProfile(primaryAuthor.key)
Expand Down Expand Up @@ -99,8 +101,14 @@ export default function BlogPostItemFooterWrapper(props: Props): JSX.Element {
{isBlogPostPage && (
<SocialShare permalink={metadata.permalink} title={metadata.title} />
)}
{isBlogPostPage && (
<ReadingTimeIndicator
totalReadTime={roundedReadTime}
authorCardRef={authorCardRef}
/>
)}
{showAuthorCard && (
<section className={styles.authorCard} aria-label="Post author details">
<section ref={authorCardRef} className={styles.authorCard} aria-label="Post author details">
<div className={styles.authorBody}>
<div className={styles.authorAvatarWrapper}>
{authorAvatar ? (
Expand Down
Loading