Developing Custom WordPress Widgets
WordPress widgets are content blocks placed in registered sidebar and footer areas through the "Appearance → Widgets" interface. A custom widget is needed when standard widgets (text, categories, menu) don't cover the task: displaying recent projects with filtering, showing a subscription form with specific logic, or rendering a widget from a third-party API.
Development of one widget takes 4–8 hours.
Basic Widget Class Structure
Any custom widget extends WP_Widget:
class My_Projects_Widget extends WP_Widget {
public function __construct() {
parent::__construct(
'my_projects_widget', // Widget ID
'Recent Projects', // Display name
[
'description' => 'Displays recent projects with category filtering',
'customize_selective_refresh' => true,
]
);
}
// Frontend: what visitors see
public function widget(array $args, array $instance): void {
$count = absint($instance['count'] ?? 3);
$category = sanitize_title($instance['category'] ?? '');
echo $args['before_widget'];
if (!empty($instance['title'])) {
echo $args['before_title'] . apply_filters('widget_title', esc_html($instance['title'])) . $args['after_title'];
}
$query_args = [
'post_type' => 'project',
'posts_per_page' => $count,
'post_status' => 'publish',
];
if ($category) {
$query_args['tax_query'] = [[
'taxonomy' => 'project_category',
'field' => 'slug',
'terms' => $category,
]];
}
$projects = new WP_Query($query_args);
if ($projects->have_posts()) {
echo '<ul class="projects-widget">';
while ($projects->have_posts()) {
$projects->the_post();
printf(
'<li><a href="%s">%s</a></li>',
esc_url(get_permalink()),
esc_html(get_the_title())
);
}
wp_reset_postdata();
echo '</ul>';
}
echo $args['after_widget'];
}
// Admin settings form
public function form(array $instance): void {
$title = esc_attr($instance['title'] ?? 'Projects');
$count = absint($instance['count'] ?? 3);
$category = esc_attr($instance['category'] ?? '');
printf(
'<p><label for="%1$s">Title:</label>
<input class="widefat" id="%1$s" name="%2$s" type="text" value="%3$s"></p>',
$this->get_field_id('title'),
$this->get_field_name('title'),
$title
);
printf(
'<p><label for="%1$s">Count:</label>
<input class="tiny-text" id="%1$s" name="%2$s" type="number" min="1" max="20" value="%3$d"></p>',
$this->get_field_id('count'),
$this->get_field_name('count'),
$count
);
// Dropdown for project categories
$categories = get_terms(['taxonomy' => 'project_category', 'hide_empty' => false]);
echo '<p><label for="' . $this->get_field_id('category') . '">Category:</label>';
echo '<select class="widefat" id="' . $this->get_field_id('category') . '" name="' . $this->get_field_name('category') . '">';
echo '<option value="">All</option>';
foreach ($categories as $cat) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr($cat->slug),
selected($category, $cat->slug, false),
esc_html($cat->name)
);
}
echo '</select></p>';
}
// Save settings
public function update(array $new_instance, array $old_instance): array {
return [
'title' => sanitize_text_field($new_instance['title']),
'count' => absint($new_instance['count']),
'category' => sanitize_title($new_instance['category']),
];
}
}
Registering the Widget
add_action('widgets_init', function () {
register_widget('My_Projects_Widget');
});
Registering a Sidebar Area
If the theme doesn't have the needed area, add via register_sidebar():
add_action('widgets_init', function () {
register_sidebar([
'name' => 'Blog Sidebar',
'id' => 'blog-sidebar',
'description' => 'Widgets in blog page sidebars',
'before_widget' => '<section id="%1$s" class="widget %2$s">',
'after_widget' => '</section>',
'before_title' => '<h3 class="widget-title">',
'after_title' => '</h3>',
]);
});
before_widget and after_widget are wrappers the widget receives in $args. The theme controls HTML structure, not the widget.
Widget with AJAX Update
For widgets that should update without reload (counter or live search):
public function widget(array $args, array $instance): void {
$widget_id = $this->id;
echo $args['before_widget'];
echo '<div class="live-counter" data-widget-id="' . esc_attr($widget_id) . '">';
echo $this->render_counter(); // initial render
echo '</div>';
echo $args['after_widget'];
}
document.querySelectorAll('.live-counter').forEach(el => {
setInterval(() => {
fetch(wpData.ajaxUrl + '?action=refresh_counter&widget=' + el.dataset.widgetId)
.then(r => r.json())
.then(data => { el.innerHTML = data.html; });
}, 30000);
});
Widgets in Block Editor
WordPress 5.8+ allows managing widgets through the block interface. Classic WP_Widget instances appear as "Legacy Widgets." For full Gutenberg editor widget support, register a block that uses the same data—this is a separate task (see "Custom Gutenberg Blocks Development" service).
If the site uses Classic Widgets (plugin), the described approach works without limitations.







