Widgets and Sidebars
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
A widget area (called a sidebar in the code, wherever it appears on the page) is a slot that a classic theme
declares and the site owner fills with widgets: a search box, a list of recent posts, a paragraph of text, a menu.
Widgets let administrators change the content around the main column without editing template files. This page is for
classic and hybrid theme developers and for administrators who look after classic sites. It adds two widget areas and a
"Latest books" widget to the Lumen Press classic theme lumen-classic, then checks the result on a running site with
WP-CLI, the REST API and the rendered HTML.
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, WPCS 3.4.1 and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2, in beta and due in December 2026, announces no change to widgets so far.
Widgets are a classic-theme feature. Block themes have no widget areas: the same jobs are done by template parts and patterns edited in the Site Editor (see Templates and Template Parts and Theme Patterns). The starting point for the theme is the skeleton of Themes Overview; template files and The Loop are covered in Classic Theme Anatomy and The Loop.
Widget areas, widgets and where they live
| Concept | What it is |
|---|---|
Widget area (sidebar) |
A named slot registered by the theme with |
Widget type |
A PHP class extending |
Widget instance |
One placed copy of a type with its own settings, identified as |
Block widget |
An instance of the |
Inactive Widgets |
The pseudo-area |
Everything is stored in the options table, not in posts:
| Option | Content |
|---|---|
|
The assignment: an array of area ID to an ordered list of instance IDs, plus |
|
One option per widget type holding every instance’s settings, keyed by number, plus a |
|
When the theme is switched, the outgoing theme’s assignment is saved in its theme mod |
Because the settings and the assignment are separate, removing an area or a widget type from the code never deletes
settings. When retrieve_widgets() reconciles the assignment (on the Widgets screen and after a theme switch), it maps
old areas onto registered ones and discards from the areas any instance whose type is no longer registered.
Version history
| Version | Change |
|---|---|
2.2, 2.8 |
Widgets and |
4.2 |
|
4.3 |
The PHP 4 style constructor |
5.6 |
|
5.8 |
The block-based widgets editor on Appearance > Widgets and in the Customizer, the |
5.9 |
The Widget Group block; the |
6.2 |
After a switch from a classic theme to a block theme, the old areas stay registered so the Template Part block can import their widgets ("Import widget area"). |
Registering the widget areas of lumen-classic
lumen-classic gets two areas: a sidebar next to the main column and a footer area. The function below is appended to
the theme’s functions.php (the rest of the file, lumen_classic_setup() and the enqueue function, is unchanged from
Themes Overview). It also loads and
registers the widget class shown later on this page.
/**
* Registers the two widget areas of Lumen Classic and the "Latest books" widget.
*/
function lumen_classic_widgets_init() {
$shared = array(
'before_widget' => '<section id="%1$s" class="widget %2$s">',
'after_widget' => '</section>',
'before_title' => '<h2 class="widget-title">',
'after_title' => '</h2>',
);
register_sidebar(
array_merge(
$shared,
array(
'id' => 'lumen-sidebar',
'name' => esc_html__( 'Book sidebar', 'lumen-classic' ),
'description' => esc_html__( 'Shown next to posts and the book catalogue.', 'lumen-classic' ),
'before_sidebar' => '<aside id="%1$s" class="widget-area %2$s" aria-label="' . esc_attr__( 'Book sidebar', 'lumen-classic' ) . '">',
'after_sidebar' => '</aside>',
)
)
);
register_sidebar(
array_merge(
$shared,
array(
'id' => 'lumen-footer',
'name' => esc_html__( 'Footer', 'lumen-classic' ),
'description' => esc_html__( 'Shown at the bottom of every page.', 'lumen-classic' ),
)
)
);
require_once get_template_directory() . '/inc/class-lumen-classic-latest-books-widget.php';
register_widget( 'Lumen_Classic_Latest_Books_Widget' );
}
add_action( 'widgets_init', 'lumen_classic_widgets_init' );
Source: Theme Handbook: Sidebars
The arguments of register_sidebar() in 7.1:
| Argument | Default | Meaning |
|---|---|---|
|
|
Unique, lowercase, no spaces. Always set it; it is the key in |
|
|
Label on the Widgets screen, in the Customizer and in |
|
empty |
Help text shown under the name. |
|
empty |
Extra class for the area in the admin; also passed to |
|
|
Wrapper of every widget. |
|
|
Wrapper of a widget title. Classic widgets use it; block widgets do not, unless they sit in a Widget Group block. |
|
empty |
Wrapper of the whole area (5.6+), printed only when the area has widgets. |
|
|
Whether the area is listed by the REST API to users who cannot edit theme options (5.9+). Administrators always see it. |
The markup arguments are printed by dynamic_sidebar() exactly as given, without escaping, so they must be trusted
strings written by the theme. Anything dynamic inside them is escaped when the area is registered: the translated
aria-label above goes through esc_attr__(). The name and description also go through esc_html__() because
translation files are input from outside the theme, which is what the WordPress Coding Standards ask for. A theme must
never put user-supplied content in these arguments.
The theme gets the widgets theme support automatically with the first register_sidebar() call; that support is
what makes Appearance > Widgets and the Customizer’s Widgets panel appear. A theme with no registered area has no
Widgets screen.
Printing a widget area
dynamic_sidebar( $id ) prints every widget of an area in order and returns false when the area is empty or unknown.
is_active_sidebar( $id ) returns true when at least one widget is assigned to it. Wrapping markup is printed only
when the area has content, so the theme checks first. In lumen-classic the sidebar lives in sidebar.php, which
get_sidebar() loads; get_sidebar( 'footer' ) would load sidebar-footer.php instead.
<?php // sidebar.php (file comment omitted).
if ( ! is_active_sidebar( 'lumen-sidebar' ) ) {
return;
}
dynamic_sidebar( 'lumen-sidebar' );
The <aside> landmark comes from before_sidebar, so sidebar.php needs no wrapper of its own. The footer area is
printed from footer.php, with its wrapper in the template:
<?php // footer.php (file comment omitted). ?>
<footer class="site-footer">
<?php if ( is_active_sidebar( 'lumen-footer' ) ) : ?>
<div class="footer-widgets">
<?php dynamic_sidebar( 'lumen-footer' ); ?>
</div>
<?php endif; ?>
<p><?php bloginfo( 'name' ); ?></p>
</footer>
<?php wp_footer(); ?>
</body>
</html>
index.php of the skeleton wraps <main> and a get_sidebar() call in a <div class="site-columns"> and ends with
get_footer() instead of printing wp_footer() and the closing tags itself.
Around the output, dynamic_sidebar() fires dynamic_sidebar_before and dynamic_sidebar_after (with the area ID),
applies the dynamic_sidebar_params filter to every widget’s wrapper arguments, and fires dynamic_sidebar for each
widget. Plugins use these hooks to add markup without touching the theme. To print one widget outside any area, for
example in a template, the_widget( 'Lumen_Classic_Latest_Books_Widget', array( 'number' => 3 ) ) instantiates the
type with the given settings and uses default wrapper markup.
The block-based widgets editor
Since WordPress 5.8, Appearance > Widgets and Appearance > Customize > Widgets are block editors: each widget area is a
container, and any block can be inserted into it. Every top-level block the owner adds becomes one instance of the
block widget type, with the block’s serialized markup in its content setting, stored in the widget_block option.
Existing classic widgets keep working: the editor shows each one in a Legacy Widget block that renders the widget’s
own form() for editing and its widget() output as a preview. The Widget Group block (5.9) groups blocks under a
title that is printed with the area’s before_title and after_title, so a group of blocks looks like a classic
widget.
A fresh 7.1.3 installation creates five block widgets (Search, and groups for Recent Posts, Recent Comments, Archives
and Categories) in the areas sidebar-1 and sidebar-2. When a classic theme is activated, WordPress maps them onto
the theme’s registered areas in order, which is why lumen-classic shows them before anything is configured.
Nothing has to be declared for the block editor: core adds the widgets-block-editor theme support on
after_setup_theme (priority 1), and wp_use_widgets_block_editor() returns it through the use_widgets_block_editor
filter. A theme or site that still needs the drag-and-drop screen has three switches:
<?php
/**
* Lumen Classic: restores the classic Widgets screen (only if the site depends on it).
*/
function lumen_classic_classic_widgets_screen() {
remove_theme_support( 'widgets-block-editor' );
}
add_action( 'after_setup_theme', 'lumen_classic_classic_widgets_screen' );
-
the theme removes the
widgets-block-editorsupport as above; -
the owner activates the official Classic Widgets plugin (maintained "to 2024, or as long as is necessary");
-
a must-use plugin filters
use_widgets_block_editor, for example with__return_false, or per user.
With the support removed, wp_use_widgets_block_editor() returns false on the test site below and the classic
screen comes back. Block widgets already saved keep rendering on the front end; on the classic screen they appear as
"Block" widgets whose form is a "Block HTML" textarea holding the raw markup. Opting out is a compatibility
measure for sites whose widgets cannot work in the block editor, not a recommendation.
Block themes never get these screens. When a site switches from a classic theme to a block theme, WordPress 6.2+
saves the old areas in the theme mod wp_classic_sidebars and keeps them registered without adding widgets
support, so in the Site Editor the Template Part block’s Advanced panel can "Import widget area" into a template part.
Widgets that only existed as PHP classes of the old theme cannot come along; see
Hybrid Themes and Migration to Block Themes.
A legacy widget: Latest books
New code should prefer blocks (see the last section), but WP_Widget is still fully supported in 7.1, many classic
sites run on such widgets, and the API explains how the stored data looks. The class below lists the latest books
(lp_book, registered by the lumen-catalog plugin) and falls back to posts when the plugin is inactive. It lives in
lumen-classic/inc/class-lumen-classic-latest-books-widget.php and is registered by lumen_classic_widgets_init()
above.
<?php
/**
* Legacy "Latest books" widget, kept to show the WP_Widget API.
*
* @package Lumen_Classic
*/
/**
* Lists the most recent books (lp_book), or posts when lumen-catalog is inactive.
*/
class Lumen_Classic_Latest_Books_Widget extends WP_Widget {
/**
* Sets the widget ID base, name and options.
*/
public function __construct() {
parent::__construct(
'lumen_latest_books',
esc_html__( 'Latest books', 'lumen-classic' ),
array(
'classname' => 'lumen-latest-books',
'description' => esc_html__( 'The most recent books in the catalogue.', 'lumen-classic' ),
'customize_selective_refresh' => true,
'show_instance_in_rest' => true,
)
);
}
/**
* Front end output.
*
* @param array $args Sidebar markup: before_widget, after_widget, before_title, after_title.
* @param array $instance Saved settings of this widget instance.
*/
public function widget( $args, $instance ) {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Latest books', 'lumen-classic' );
$title = apply_filters( 'widget_title', $title, $instance, $this->id_base );
$number = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 5;
$books = new WP_Query(
array(
'post_type' => post_type_exists( 'lp_book' ) ? 'lp_book' : 'post',
'posts_per_page' => $number,
'no_found_rows' => true,
'ignore_sticky_posts' => true,
)
);
if ( ! $books->have_posts() ) {
return;
}
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- markup registered by the theme.
echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<ul>';
while ( $books->have_posts() ) {
$books->the_post();
printf( '<li><a href="%s">%s</a></li>', esc_url( get_permalink() ), esc_html( get_the_title() ) );
}
echo '</ul>';
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
wp_reset_postdata();
}
/**
* Settings form on the Widgets screen and in the Legacy Widget block.
*
* @param array $instance Current settings.
* @return string Default return is 'noform'.
*/
public function form( $instance ) {
$title = isset( $instance['title'] ) ? $instance['title'] : '';
$number = isset( $instance['number'] ) ? absint( $instance['number'] ) : 5;
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"><?php esc_html_e( 'Title:', 'lumen-classic' ); ?></label>
<input class="widefat" type="text" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>" value="<?php echo esc_attr( $title ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'number' ) ); ?>"><?php esc_html_e( 'Number of books:', 'lumen-classic' ); ?></label>
<input class="tiny-text" type="number" min="1" max="20" id="<?php echo esc_attr( $this->get_field_id( 'number' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'number' ) ); ?>" value="<?php echo esc_attr( $number ); ?>">
</p>
<?php
return '';
}
/**
* Sanitizes the submitted settings before they are saved.
*
* @param array $new_instance Values just sent from the form.
* @param array $old_instance Previously saved values.
* @return array Settings to save.
*/
public function update( $new_instance, $old_instance ) {
// Fields missing from the request (wp widget update, REST) keep their saved value.
$instance = wp_parse_args( $new_instance, $old_instance );
return array(
'title' => sanitize_text_field( $instance['title'] ?? '' ),
'number' => min( 20, max( 1, absint( $instance['number'] ?? 5 ) ) ),
);
}
}
Source: Code Reference: WP_Widget
| Part | Role |
|---|---|
|
|
|
Front end output. It must print |
|
The settings form, shown on the classic screen and inside the Legacy Widget block. |
|
Returns the settings to save, or |
update() merges the new values over the saved ones with wp_parse_args(): wp widget update and REST clients send
only the fields they change, and a version that read $new_instance alone blanked the title on the test site when only
--number was updated. The phpcs:ignore comments mark the trusted wrapper markup as the only unescaped output; the
file passes the WordPress ruleset of WPCS 3.4.1.
A widget type registered by a theme exists only while that theme is active (see the pitfalls below). For a real site
the class belongs in the lumen-catalog plugin, next to the post type it lists and its block replacement.
Checking the result on a running site
The commands below use the Docker Compose stack of
Local Development Environments (services db, wordpress and
cli, with lumen-catalog active) on a fresh volume, with one more line in its &wp-volumes list:
./themes/lumen-classic:/var/www/html/wp-content/themes/lumen-classic. The site is installed under
https://lumenpress.test, and the books catalogue holds four published lp_book posts.
docker compose run --rm cli wp core install --url=https://lumenpress.test --title="Lumen Press" \
--admin_user=admin --admin_email=admin@lumenpress.test --skip-email
docker compose run --rm cli wp rewrite structure '/%postname%/'
docker compose run --rm cli wp theme activate lumen-classic
docker compose run --rm cli wp sidebar list
docker compose run --rm cli wp widget list lumen-sidebar --fields=name,id,position
Source: WP-CLI Commands: wp sidebar list
Success: Switched to 'Lumen Classic' theme.
name id description
Book sidebar lumen-sidebar Shown next to posts and the book catalogue.
Footer lumen-footer Shown at the bottom of every page.
Inactive Widgets wp_inactive_widgets Drag widgets here to remove them from the sidebar but keep their settings.
name id position
block block-2 1
block block-3 2
block block-4 3
The three block widgets are the installation defaults mapped onto the first area. The next commands move all default widgets to Inactive Widgets, place the "Latest books" widget and a block widget, and read back the stored data.
docker compose run --rm cli wp widget reset lumen-sidebar lumen-footer
docker compose run --rm cli wp widget add lumen_latest_books lumen-sidebar 1 --title="New from Lumen Press" --number=3
docker compose run --rm cli wp widget add block lumen-footer \
--content='<!-- wp:paragraph --><p>Submissions open every spring.</p><!-- /wp:paragraph -->'
docker compose run --rm cli wp widget list lumen-sidebar --fields=name,id,position,options
docker compose run --rm cli wp widget update lumen_latest_books-1 --number=50
docker compose run --rm cli wp option get widget_lumen_latest_books --format=json
docker compose run --rm cli wp option get sidebars_widgets --format=json
Source: WP-CLI Commands: wp widget
Sidebar 'lumen-sidebar' reset.
Sidebar 'lumen-footer' reset.
Success: Reset 2 of 2 sidebars.
Success: Added widget to sidebar.
Success: Added widget to sidebar.
name id position options
lumen_latest_books lumen_latest_books-1 1 {"title":"New from Lumen Press","number":3}
Success: Widget updated.
{"_multiwidget":1,"1":{"title":"New from Lumen Press","number":20}}
{"wp_inactive_widgets":["block-2","block-3","block-4","block-5","block-6"],"lumen-sidebar":["lumen_latest_books-1"],"lumen-footer":["block-7"]}
wp widget add and wp widget update run the widget’s update() method, so the requested 50 books were clamped to 20
and the title kept its value (the number was set back to 3 before the requests below). wp widget reset deletes
nothing: the defaults are now in wp_inactive_widgets, and wp widget delete <id> removes them for good.
wp widget move <id> --sidebar-id=<area> and wp widget deactivate <id> cover the other drag-and-drop operations.
The REST API view
The block-based Widgets screen is a REST client. The edit_theme_options capability is needed to read areas and
widgets, so the requests are made as admin with wp eval and rest_do_request():
docker compose run --rm cli wp eval --user=admin '
$r = new WP_REST_Request( "GET", "/wp/v2/widgets" );
$r->set_param( "sidebar", "lumen-sidebar" );
$r->set_param( "context", "edit" );
foreach ( rest_do_request( $r )->get_data() as $w ) {
echo wp_json_encode( array( "id" => $w["id"], "id_base" => $w["id_base"], "raw" => $w["instance"]["raw"] ) ), "\n";
}'
Source: REST API Handbook: Widgets
{"id":"lumen_latest_books-1","id_base":"lumen_latest_books","raw":{"title":"New from Lumen Press","number":3}}
Next to raw, the instance object carries encoded (the base64 of the serialized settings) and hash (an HMAC
keyed with the site’s salts), which is all a client gets when show_instance_in_rest is off. /wp/v2/sidebars lists
each area with its arguments and widget IDs, and /wp/v2/widget-types the registered types.
The rendered page
docker compose exec wordpress curl -s -H 'Host: lumenpress.test' -H 'X-Forwarded-Proto: https' http://localhost/ \
| sed -n '/<\/main>/,/<\/footer>/p'
Source: Theme Handbook: Widgets
</main>
<aside id="lumen-sidebar" class="widget-area " aria-label="Book sidebar"><section id="lumen_latest_books-1" class="widget lumen-latest-books"><h2 class="widget-title">New from Lumen Press</h2><ul><li><a href="https://lumenpress.test/books/night-ferry/">Night Ferry</a></li><li><a href="https://lumenpress.test/books/a-map-of-quiet-rooms/">A Map of Quiet Rooms</a></li><li><a href="https://lumenpress.test/books/salt-and-signal/">Salt and Signal</a></li></ul></section></aside></div>
<footer class="site-footer">
<div class="footer-widgets">
<section id="block-7" class="widget widget_block widget_text"><p class="wp-block-paragraph">Submissions open every spring.</p></section> </div>
<p>Lumen Press</p>
</footer>
The <aside> comes from before_sidebar with the area ID as %1$s (and an empty %2$s, because the area has no
class). Each widget is wrapped in the <section> of before_widget; the classic widget’s title uses before_title,
while the block widget gets the classes widget_block plus one derived from its block (widget_text for a paragraph).
While the footer area was empty, is_active_sidebar( 'lumen-footer' ) was false and no footer-widgets wrapper
was printed.
A Heading block inside a block widget prints its own <h2 class="wp-block-heading">; a Widget Group block with
{"title":"Submissions"} instead printed <h2 class="widget-title">Submissions</h2>, like the classic widgets.
Errors and pitfalls
-
Theme switches rearrange widgets. On the test site,
wp theme activate twentytwentyfivefollowed bywp theme activate lumen-classicleft the "Latest books" widget in no area at all (its type is registered by the theme, so it did not exist while Twenty Twenty-Five was active) and moved the five previously inactive default widgets back intolumen-sidebarandlumen-footer. The settings survived inwidget_lumen_latest_books. After any theme switch, check withwp widget listand repair withwp widget move,deactivateandadd. -
Missing or renamed
id. Generatedsidebar-<n>IDs shift when a plugin registers an area first, and renaming anidin a theme update leaves the widgets of the old area unprinted. -
Registering at the wrong time. Use
widgets_init(fired oninitat priority 1), never a template. -
Trusting the instance. Sanitize in
update()and still escape inwidget(); see Security for Developers. -
Widgets that break in the block editor. A widget whose
form()depends on scripts loaded only on the classic screen, or that callswp.editor.initialize(), fails inside the Legacy Widget block. The fix is to load the scripts on the Widgets screen properly or to replace the widget with a block; removingwidgets-block-editoronly hides the problem.
When a block is better than a widget
Blocks replace widgets for new development. A WP_Widget works only in the widget areas of a classic theme; a block
works in posts, pages, patterns, template parts of block themes and, through the block widget type, in classic widget
areas too.
Legacy WP_Widget |
Block (lumen/latest-books) |
|
|---|---|---|
Where it can appear |
Classic widget areas only |
Posts, pages, patterns, block theme templates and parts, and classic widget areas as a block widget |
Editing experience |
A PHP form; preview only in the Legacy Widget block or the Customizer |
Visual editing with live preview, block supports (colour, spacing, typography) and the inspector |
Survives a switch to a block theme |
No; at best imported through "Import widget area" if the type is still registered |
Yes, when it is registered by a plugin |
Server rendering |
|
|
REST and editor integration |
Settings visible only with |
Attributes in the serialized markup |
A widget is still reasonable when a classic site must keep working without changes, or when a third-party widget has
no block equivalent yet. For new work on Lumen Press, the catalogue list is the dynamic block lumen/latest-books,
registered by lumen-catalog and described in Static and Dynamic
Blocks. In lumen-classic it is placed in a widget area as a block widget. With a minimal server-rendered block
registered under that name, the command below rendered the block’s <ul class="wp-block-lumen-latest-books"> inside
the area’s <section id="block-8" class="widget widget_block"> wrapper.
docker compose run --rm cli wp widget add block lumen-sidebar \
--content='<!-- wp:heading --><h2 class="wp-block-heading">New from Lumen Press</h2><!-- /wp:heading --><!-- wp:lumen/latest-books /-->'
Source: WP-CLI Commands: wp widget add
Once the block exists, the legacy widget can stop being offered for new placements without breaking the instances
already in use. The filter below removes it from the Legacy Widget block’s list; existing lumen_latest_books-*
instances keep rendering. On the test site, get_legacy_widget_block_editor_settings() then listed it after the 17
core types hidden by default, all of which have block equivalents.
<?php
/**
* Hides the legacy "Latest books" widget from the Legacy Widget block once the
* lumen/latest-books block replaces it. Existing instances keep working.
*
* @param string[] $widget_types Widget id_base values the Legacy Widget block does not offer.
* @return string[]
*/
function lumen_classic_hide_legacy_widgets( $widget_types ) {
$widget_types[] = 'lumen_latest_books';
return $widget_types;
}
add_filter( 'widget_types_to_hide_from_legacy_widget_block', 'lumen_classic_hide_legacy_widgets' );
The same handbook page adds a block transform from core/legacy-widget that reads instance.raw (hence
show_instance_in_rest) and turns each placed widget into the new block. Converting widget areas into template parts
is part of Hybrid Themes and Migration to Block
Themes.
What older books and tutorials get wrong
| Older advice | Current practice |
|---|---|
Widgets are built only as |
Since 5.8 the Widgets screen and the Customizer panel are block editors; blocks are the default way to fill an area, and |
A widget’s constructor calls |
Deprecated since 4.3; call |
|
Always pass an |
|
Sanitize in |
Block themes have a Widgets screen |
They do not; widget areas become template parts and patterns, and 6.2+ can import a former theme’s areas. |
These corrections are also collected in Where Older Books and Tutorials Are Outdated.