Default Header Images Generator

register_default_headers with the theme support it needs and the render callback it is useless without, plus the %s path placeholder done properly.

Enable JavaScript to customise; default output below.

Relative to the theme directory. These are theme files, not media library uploads.

Used for the default-image setting, so it should be one of the images below.

The choices
Live preview custom-header.php
<?php
/**
 * Default header images for the theme.
 *
 * These appear as choices under Appearance → Header. They are theme files, not uploads: the
 * paths are relative to the theme directory, and register_default_headers() does nothing at all
 * unless the theme also declares custom-header support.
 */

/**
 * Declares header support and registers the choices.
 */
function my_theme_custom_header_setup() {
	add_theme_support(
		'custom-header',
		array(
			'default-image'          => get_template_directory_uri() . '/assets/headers/mountains.jpg',
			'width'                  => 1600,
			'height'                 => 480,
			// Without this the admin offers a cropper that will not let the user keep their own
			// aspect ratio, which is the right trade only if the design truly requires the size.
			'flex-width'             => true,
			'flex-height'            => true,
			'uploads'                => true,
			'header-text'            => true,
			'default-text-color'     => 'ffffff',
			'wp-head-callback'       => 'my_theme_header_style',
		)
	);

	// The %s in these paths is filled in by WordPress with the theme directory URI. Using a
	// hardcoded URL here breaks the moment the site moves domain or the theme is renamed.
	register_default_headers(
		array(
			'mountains' => array(
				'url'           => '%s/assets/headers/mountains.jpg',
				'thumbnail_url' => '%s/assets/headers/mountains-thumb.jpg',
				'description'   => _x( 'Mountains at dawn', 'header image description', 'my-theme' ),
			),
			'harbour' => array(
				'url'           => '%s/assets/headers/harbour.jpg',
				'thumbnail_url' => '%s/assets/headers/harbour-thumb.jpg',
				'description'   => _x( 'Harbour in winter', 'header image description', 'my-theme' ),
			),
		)
	);
}
add_action( 'after_setup_theme', 'my_theme_custom_header_setup' );

/**
 * Prints the header styles.
 *
 * Registered as wp-head-callback above. Without a callback the header image is registered and
 * never rendered, which is the commonest reason "the image does not show up".
 */
function my_theme_header_style() {
	$image = get_header_image();

	if ( ! $image ) {
		return;
	}

	?>
	<style id="my_theme-header-css">
		.site-header {
			background-image: url( <?php echo esc_url( $image ); ?> );
			background-position: center;
			background-size: cover;
			min-height: 480px;
		}
	</style>
	<?php
}

Output is valid and updates as you type.

register_default_headers() on its own does nothing. It registers choices for a feature the theme has not declared, so Appearance → Header does not appear and the images never show up. It needs add_theme_support( 'custom-header' ), and it needs something to actually render the image, which is the wp-head-callback.

Those two omissions are why most snippets for this do not work when pasted. All three parts are generated here together.

The other detail worth knowing: the url and thumbnail_url values take a literal %s, which WordPress replaces with the theme directory URI. A hardcoded https://example.com/wp-content/themes/... works until the site changes domain, and then every header image 404s.

How to use

  1. Put the images in a folder inside the theme and list them, with a thumbnail for each.
  2. Set the size, and decide whether the cropper forces it.
  3. Drop the file in the theme and require it from functions.php.

Example

function my_theme_custom_header_setup() {
	add_theme_support(
		'custom-header',
		array(
			'default-image'          => get_template_directory_uri() . '/assets/headers/mountains.jpg',
			'width'                  => 1600,
			'height'                 => 480,
			'flex-width'             => true,
			'flex-height'            => true,
			'uploads'                => true,
			'header-text'            => true,
			'default-text-color'     => 'ffffff',
			'wp-head-callback'       => 'my_theme_header_style',
		)
	);

	register_default_headers(
		array(
			'mountains' => array(
				'url'           => '%s/assets/headers/mountains.jpg',
				'thumbnail_url' => '%s/assets/headers/mountains-thumb.jpg',
				'description'   => _x( 'Mountains at dawn', 'header image description', 'my-theme' ),
			),
		)
	);
}
add_action( 'after_setup_theme', 'my_theme_custom_header_setup' );

The callback that renders it is generated alongside, because without one the image is a registered choice that appears nowhere on the front end.

Pitfalls

These are theme files, not uploads. The images have to ship with the theme, in a folder inside it. A child theme registering headers needs get_stylesheet_directory_uri() rather than the template one, and %s resolves to the template directory, so a child theme’s own images need the full URI.

%s is not a typo. WordPress runs the registered URLs through sprintf with the theme directory URI. Writing a full URL there survives until the domain changes; writing %s never breaks.

Turning off flex width and height forces the cropper. The user cannot then keep their own framing, and a photograph cropped to an exact 1600 × 480 usually loses the subject. Only do it when the design genuinely depends on the ratio.

Hiding header text is not the same as removing it. Setting header-text to false should hide the title and tagline visually while leaving them in the markup for screen readers, which is what the generated CSS does with clip-path. Using display: none removes them from the accessibility tree as well, and the site title is usually the most useful thing on the page for a screen reader user.

Thumbnails are not optional in practice. Without thumbnail_url the admin screen loads the full-size image for every choice, so a picker with eight 1600-pixel headers downloads eight megabytes.

Block themes handle this differently. A block theme puts the header in a template part with a cover block, and custom-header support is mostly irrelevant there. This is for classic themes, and for a block theme still carrying a classic header.

after_setup_theme is the hook. Registering on init is too late for theme support to be seen in places that check it early, and registering at file load is too early for translation functions.

Compatibility

Everything runs in the browser: nothing is uploaded and nothing is stored.

register_default_headers() and custom-header support have been in WordPress since 3.4 and the argument names have not changed. wp-head-callback defaults to _custom_header_h1_style, which does almost nothing useful, so a real callback is generated here rather than left out.

The description goes through _x() with a context string, because a two-word image label is exactly the kind of text a translator needs context for. Every value is escaped for a PHP single-quoted string: the hostile fixture includes an apostrophe, */, ?> and ${x}, and the generated file passes php -l with the same token structure as the default one.

The generated CSS uses background-size: cover and a min-height, which is the arrangement that survives a flexible image size. A theme using an <img> in the header instead should render get_header_image_tag() rather than this CSS, and that is a different callback.

Frequently asked questions

Why is Appearance → Header missing?
Because add_theme_support( 'custom-header' ) has not run, or has run too late. register_default_headers() alone does not create the screen.
Why does the image not appear on the front end?
Because nothing renders it. The registered header is data; the wp-head-callback or a get_header_image_tag() call in the template is what puts it on the page.
What size should the header be?
Whatever the design needs, at twice the display width for high-density screens, and under about 300 KB after compression. 1600 × 480 is a common choice for a full-width banner.
Can the user upload their own?
Yes, with uploads set to true, which is the default here. They then get the cropper, constrained by your flex settings.
Does this work in a child theme?
Yes, and mind the paths: %s resolves to the parent theme directory. For images living in the child theme, use get_stylesheet_directory_uri() and build the URLs yourself rather than relying on the placeholder.

From the people who built this tool

WP Adminify

The WordPress admin, rebuilt: a dashboard worth looking at, menu and column control, a real file manager and the login page your client sees.

See WP Adminify Free version on WordPress.org

Weekly drops

New tools, when there are new tools

One email when something worth using ships. No schedule to fill, so no filler.

Your address goes nowhere else, and one click unsubscribes.