Show Estimated Reading Time in WordPress (1 function, no plugin)

A WordPress estimated reading time should be predictable, server-rendered, and honest about what it counts. The helper below reads stored post content, removes markup, counts Unicode-aware word tokens, divides by 225 words per minute, and returns a label such as “5 min read.”

My recommendation changed when WordPress added a native option. Use the core block when you only need reading time in a block template. Use this function when you need one reusable calculation in a classic theme, shortcode, archive card, byline, or custom integration. Do not install a separate plugin until those two options fail your actual requirement.

What this snippet does

The helper turns raw post content into a rounded reading-time label without storing another value in the database. Its behavior is intentionally narrow and testable:

  • Reads the current post or an explicit post ID with get_post_field().
  • Falls back to 225 WPM when the supplied speed is zero or invalid.
  • Removes registered shortcodes, Gutenberg block comments, and HTML tags before counting.
  • Counts accented Latin words, numbers, combining marks, and words containing straight or curly apostrophes.
  • Returns an empty string for missing or empty content instead of inventing a “1 min read” label.
  • Rounds a positive estimate upward, so 226 words at 225 WPM becomes 2 minutes.
  • Uses WordPress pluralization and localized number formatting for the output.

For a block theme, try the WordPress Time to Read block first. It is a dynamic, server-rendered core block that can display reading time or word count. Its documented defaults are 189 WPM and a range display. That means its output will not necessarily match this helper’s 225 WPM, single-number estimate.

Install and use

Put the named function in a site-specific mu-plugin or a child theme, then call it only where you want the label. A small mu-plugin is safer than a parent theme because a theme update cannot erase it. WordPress currently recommends PHP 8.3 or newer, while the WordPress requirements page says the software still runs on PHP 7.4 or newer and warns that legacy PHP versions are end-of-life.

<?php
/**
 * Return an estimated reading time for one WordPress post.
 *
 * @param int $post_id Post ID. Defaults to the current post.
 * @param int $wpm     Reading speed. Invalid values fall back to 225.
 * @return string
 */
function gt_estimated_reading_time( $post_id = 0, $wpm = 225 ) {
    $post_id = $post_id ? absint( $post_id ) : get_the_ID();
    if ( ! $post_id ) {
        return '';
    }

    $content = get_post_field( 'post_content', $post_id, 'raw' );
    if ( ! is_string( $content ) || '' === trim( $content ) ) {
        return '';
    }

    $wpm = absint( $wpm );
    if ( 1 > $wpm ) {
        $wpm = 225;
    }

    $content = strip_shortcodes( $content );
    $content = preg_replace( '/<!--[\s\S]*?-->/', ' ', $content );
    $content = wp_strip_all_tags( $content, true );
    $content = html_entity_decode(
        $content,
        ENT_QUOTES | ENT_HTML5,
        get_bloginfo( 'charset' ) ?: 'UTF-8'
    );

    $matched = preg_match_all(
        "/[\p{L}\p{N}][\p{L}\p{N}\p{M}]*(?:['\x{2019}][\p{L}\p{N}][\p{L}\p{N}\p{M}]*)*/u",
        $content,
        $matches
    );
    $words = false === $matched ? str_word_count( $content ) : $matched;

    if ( 1 > $words ) {
        return '';
    }

    $minutes = max( 1, (int) ceil( $words / $wpm ) );

    return sprintf(
        /* translators: %s: Number of minutes. */
        _n( '%s min read', '%s min read', $minutes, 'gt' ),
        number_format_i18n( $minutes )
    );
}

For a classic template, echo the escaped return value inside the Loop. If an editor needs placement control, register the optional shortcode callback and place [reading_time] in a Shortcode block. Keep the calculation in the named helper so templates and shortcodes cannot drift into different formulas.

<?php
// Classic theme template, inside the Loop.
echo esc_html( gt_estimated_reading_time() );

/**
 * Shortcode: [reading_time] or [reading_time wpm="200"]
 */
add_shortcode(
    'reading_time',
    static function ( $atts ) {
        $atts = shortcode_atts(
            array( 'wpm' => 225 ),
            $atts,
            'reading_time'
        );

        return esc_html(
            gt_estimated_reading_time( 0, (int) $atts['wpm'] )
        );
    }
);

Do not prepend the label through the_content unless every template truly needs it. Filters can run in feeds, REST responses, excerpts, loops, and secondary queries. A deliberate template call is easier to reason about, cache, style, and remove.

If you do add a content filter, guard it with is_singular(), in_the_loop(), and is_main_query(). Also decide whether your threshold is based on the same count as the label. Two counting methods create annoying edge cases around a 500-word cutoff.

How it works

A WordPress estimated reading time is a model, not a measurement of an individual reader. Marc Brysbaert’s 2019 reading-rate meta-analysis covered 190 studies and 18,573 participants, estimating 238 WPM for adult English silent non-fiction and 260 WPM for fiction. I use 225 WPM here as a conservative site default, not as a claim that every reader moves at that speed.

1,000-word postRounded estimateInterpretation
189 WPM6 minWordPress core block default
225 WPM5 minThis helper’s default
238 WPM5 minMeta-analysis non-fiction estimate
300 WPM4 minFaster-reader assumption
The same article can produce a different label when the WPM assumption changes.

The processing order matters. strip_shortcodes() works only on shortcodes registered during the request. The official strip_shortcodes() reference also shows that WordPress intersects detected tags with the active shortcode registry. A shortcode left behind by a disabled plugin is therefore not guaranteed to disappear. A registered [gallery] disappears; the same text can remain if its registration is missing.

Next, the regular expression removes Gutenberg block comments. wp_strip_all_tags() removes HTML while keeping text node content, and html_entity_decode() converts stored entities before the token count. The Unicode expression is more useful than str_word_count() for words such as cafe with an accent or l’ete with typographic punctuation. The PHP counter remains only as a fallback if invalid UTF-8 makes the Unicode match fail.

Test inputObserved outputWhat the result proves
225 words at 225 WPM1 min readExact boundary
226 words at 225 WPM2 min readRounds upward
Zero WPMUses 225 WPMDivision-by-zero guard
Empty postEmpty stringNo false one-minute label
Registered enclosing shortcodeTag and inner text excludedCore shortcode behavior
Unregistered shortcodeToken can remainActive registry matters
Code blockCode text countedText nodes survive tag stripping
Image-only contentEmpty stringAlt attributes are not counted
Synced-pattern referenceNo referenced textRaw storage is not rendered output
Chinese text without spacesOne tokenLocale limitation
The helper was exercised with these edge cases in WordPress itself.

The compatibility matrix produced 196 passing checks. I ran 14 cases in each of 14 environments: WordPress 6.9.5 and 7.0.2 across PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, and 8.5. Every environment registered the core Time to Read block and returned the expected result for all cases.

That is a compatibility check, not a performance promise. The helper does a bounded pass over one stored content string. On a cached public page it normally runs only when the cache is generated. If an archive calls it for dozens of posts, calculate once per card render and avoid calling it again in the same request.

Know the model’s blind spots before you publish the label. Registered enclosing shortcodes lose their inner text. Dynamic blocks and synced patterns can generate readable text that is absent from the referring post’s raw content. Media time is ignored. A token regex is not a correct segmenter for every language. If those sources form a large share of your page, render-aware counting is a separate, more expensive problem.

For example, a registered [product_box] may output a substantial product description that this helper excludes. That is usually the right editorial choice because the box is supplementary, but it is still a choice. Document it for your site rather than pretending the estimate measures every second on the page.

Download and source

Copy the tested helper above, then keep the source under version control with the rest of your site functionality. A snippet manager is convenient, but a small, reviewable file is easier to audit during a PHP or WordPress upgrade.

  • Gist index: gist.github.com/wpgaurav
  • Bundled in the Functionalities plugin as a toggleable module
  • Default shortcode example: [reading_time]
  • Tested matrix: WordPress 6.9.5 and 7.0.2 on PHP 7.4 through 8.5, 196 of 196 checks passed
  • Rollback: remove the helper and integration, remove each visible placement, and clear the page cache

FAQs

Why does the snippet use 225 words per minute?

It is a conservative editorial default, not a biological constant. A 2019 meta-analysis of 190 studies and 18,573 participants estimated 238 WPM for English silent non-fiction. Technical material, unfamiliar terminology, language, and reader ability can slow that down. Change the second function argument when your audience or content needs a different assumption.

Should I use this snippet or the WordPress Time to Read block?

Use the core block when its template placement and output suit your site. It is server-rendered, can show time or word count, defaults to 189 WPM, and shows a range by default. Use the helper when you need a classic-theme template call, shortcode placement, custom wording, or one calculation shared across cards and bylines.

Does the snippet count code blocks, images, and shortcodes?

It counts visible text inside code blocks because code is still text after HTML tags are removed. It does not count image alt attributes. WordPress removes registered shortcodes, including the enclosed content of registered enclosing shortcodes. An unregistered shortcode can leave a token behind and slightly inflate the count.

Does it count synced patterns and dynamic block output?

Not reliably. A synced pattern is stored as a separate wp_block post, while the referring post stores a block reference. Dynamic blocks also produce output at render time. This helper deliberately reads raw post content, so referenced or generated text is outside its count unless you build a render-aware version.

Will the Unicode word counter work for every language?

No single whitespace-style counter works equally well for every writing system. The regular expression handles accented Latin words, numbers, combining marks, and apostrophes better than str_word_count. A Chinese or Japanese sentence without spaces may still count as one token. Use a locale-aware segmenter for those languages.

How do I remove the reading-time feature safely?

Remove the helper and its hook or shortcode registration, then remove each shortcode placement or Time to Read block from templates and content. Clear the page cache afterward. The helper does not write post meta or change the database, so there is no stored value to migrate.