WordPress Post Reading Time Shortcode

A WordPress post reading time shortcode shows readers how long an article takes, such as “4 min read”, next to the title or at the top of the post. [reading_time] estimates it from the post’s word count and works inside the post content, in a Shortcode block in a block theme template and in PHP templates.

The snippet

<?php
/**
 * [reading_time] shortcode: estimated reading time of a post.
 * Usage: [reading_time], [reading_time wpm="250"], [reading_time post_id="42" label="false"]
 */

add_action( 'init', 'dpc_reading_time_register' );

function dpc_reading_time_register() {
	add_shortcode( 'reading_time', 'dpc_reading_time_shortcode' );
}

/**
 * Reading time in whole minutes, from the raw post content.
 * Returns 0 for a post without words.
 */
function dpc_get_reading_time( $post = null, $words_per_minute = 200 ) {
	$post = get_post( $post );
	if ( ! $post ) {
		return 0;
	}

	// Raw content: no the_content filter, so no recursion when the shortcode sits in the post itself.
	$text = strip_shortcodes( $post->post_content );
	$text = wp_strip_all_tags( $text ); // Also removes the <!-- wp:... --> block comments.
	$text = html_entity_decode( $text, ENT_QUOTES, get_bloginfo( 'charset' ) );

	$words = preg_split( '/\s+/u', trim( $text ), -1, PREG_SPLIT_NO_EMPTY );
	$count = is_array( $words ) ? count( $words ) : 0;

	return $count > 0 ? (int) ceil( $count / max( 1, (int) $words_per_minute ) ) : 0;
}

function dpc_reading_time_shortcode( $atts ) {
	$atts = shortcode_atts(
		array(
			'wpm'     => 200,
			'post_id' => 0,
			'label'   => 'true',
		),
		$atts,
		'reading_time'
	);

	$wpm = absint( $atts['wpm'] );
	if ( $wpm < 50 || $wpm > 1000 ) {
		$wpm = 200;
	}

	$post = absint( $atts['post_id'] ) ? get_post( absint( $atts['post_id'] ) ) : get_post();
	if ( ! $post || ( ! is_post_publicly_viewable( $post ) && ! current_user_can( 'read_post', $post->ID ) ) ) {
		return '';
	}

	$minutes = dpc_get_reading_time( $post, $wpm );
	if ( 0 === $minutes ) {
		return '';
	}

	$text = wp_validate_boolean( $atts['label'] )
		/* translators: %d: number of minutes. */
		? sprintf( _n( '%d min read', '%d min read', $minutes, 'dpc' ), $minutes )
		: (string) $minutes;

	return '<span class="dpc-reading-time">' . esc_html( $text ) . '</span>';
}

Use it

  • [reading_time] prints <span class="dpc-reading-time">4 min read</span> for the current post.
  • wpm sets words per minute (50 to 1000, default 200). Anything else falls back to 200.
  • label="false" prints only the number, for your own wording around it.
  • post_id="42" measures another post, for example in a sidebar or a related-posts list.
  • In a PHP template: echo do_shortcode( '[reading_time]' );, or dpc_get_reading_time() for the bare number.

How it works

  • The words come from the raw post_content. The shortcode never runs the the_content filter, which would render the shortcode again inside itself and loop until PHP runs out of memory, which is the bug in the older version of this post.
  • strip_shortcodes() removes shortcodes, wp_strip_all_tags() removes HTML and the <!-- wp:... --> block comments, so markup and block settings are not counted as words.
  • Words are split on whitespace with a Unicode-aware pattern, so accented words count correctly, unlike str_word_count().
  • The result is rounded up, so a short post shows 1 minute, and a post with no words prints nothing.
  • Attributes go through shortcode_atts(), absint() and wp_validate_boolean(), the output through esc_html(). A private or draft post passed in post_id only shows for users who can read it.

The older version also had a gt attribute that always ran because the string “false” is truthy; it is no longer needed and is ignored if old shortcodes still pass it.

Add it with Scripts Organizer

In Scripts Organizer go to Code Blocks → Add New and give the block a name. Set Trigger location to Everywhere, tick PHP under Script location, paste the code into the PHP editor and publish. Leave Action/Hook empty. Everywhere registers the shortcode on every request, including the REST requests the block editor uses to preview a Shortcode block; Conditions would only register it on matching front-end pages.

The same code also works unchanged in a small plugin or an mu-plugin.

Customise it

  • Different wording: change '%d min read' (both strings in _n()), for example to '%d minute read' and '%d minutes read'.
  • Automatically above every post, without a shortcode: hook a filter on the_content that prepends do_shortcode( '[reading_time]' ) when is_singular( 'post' ) && in_the_loop().

Already using Read time without the plugin? This version adds a template helper and attribute validation. For another small shortcode, see Fluid Type Preview shortcode.

Gotchas

  • Text that blocks render dynamically (query loops, reusable patterns, synced patterns) is not in post_content and is not counted.
  • Chinese, Japanese and Thai don’t separate words with spaces, so the count is far too low; count characters with mb_strlen() and divide by about 500 instead.
  • The tag reading_time has no prefix so existing shortcodes keep working. If another plugin registers the same tag, the one added last wins; rename it in add_shortcode() and in shortcode_atts() if needed.

In the Scripts Organizer library Import it with one click from Scripts Organizer.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You’ll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You'll also get DPlugins product updates. No spam, and you can unsubscribe anytime.