Plugins & Extending jQuery
Writing a jQuery plugin with $.fn, chainability, handling multiple elements, and a complete character-counter example.
$.fn — where every jQuery method actually lives
Every method you've used so far (.text(), .on(), .addClass()) is just a function attached to $.fn, the prototype every jQuery object inherits from. Writing a jQuery plugin means adding your own function to that same object — the instant you do, it's callable on any jQuery selection, exactly like a built-in method:
$.fn.highlight = function () {
return this.css('background-color', 'yellow');
};
$('.important').highlight();
this inside a plugin function refers to the jQuery object the plugin was called on — whatever elements $('.important') matched. Wrapping the whole thing in an IIFE (immediately-invoked function expression) that passes in jQuery as $ is the standard, safe way to define a plugin, since it guarantees $ means jQuery inside the plugin even on a page where $ has been reassigned to something else (a real risk when multiple libraries share the global $):
(function ($) {
$.fn.highlight = function () {
return this.css('background-color', 'yellow');
};
}(jQuery));
Chainability: always return this
jQuery's fluent, chainable style — $('#el').addClass('x').css('color', 'red') — only works because every built-in method returns the same jQuery object it operated on. A plugin that doesn't do the same breaks the chain for anyone using it:
$.fn.highlight = function () {
this.css('background-color', 'yellow'); // no return — breaks chaining
};
$('.important').highlight().fadeOut(); // TypeError: .fadeOut is not a function on undefined
$.fn.highlight = function () {
return this.css('background-color', 'yellow'); // returns the jQuery object — chaining works
};
$('.important').highlight().fadeOut(); // works correctly
Handling multiple matched elements with .each()
A jQuery selection can match zero, one, or many elements, and a plugin should behave correctly for all three. Wrapping the plugin body in .each() — and still returning this afterward — is the standard pattern once a plugin needs to do more than a single chainable jQuery call per element (like attaching a separate event handler to each matched element individually):
(function ($) {
$.fn.confirmDelete = function (message) {
return this.each(function () {
const $el = $(this);
$el.on('click', function (event) {
if (!window.confirm(message || 'Are you sure?')) {
event.preventDefault();
}
});
});
};
}(jQuery));
<a href="/posts/42/delete" class="delete-link">Delete post</a>
<a href="/posts/43/delete" class="delete-link">Delete post</a>
$('.delete-link').confirmDelete('Delete this post permanently?');
this.each(function () { ... }) runs the callback once per matched element, with this inside the callback bound to the raw DOM element (hence wrapping it as $(this) to use jQuery methods on it) — and because .each() itself returns the original jQuery object, return this.each(...) satisfies chainability automatically, with no separate return this; needed afterward.
Accepting options, with sensible defaults
A configurable plugin merges caller-supplied options over a set of defaults using $.extend(), which is jQuery's shallow object-merge utility:
(function ($) {
$.fn.characterCounter = function (options) {
const settings = $.extend({
maxLength: 280,
counterClass: 'char-counter',
}, options);
return this.each(function () {
const $input = $(this);
const $counter = $('<div>')
.addClass(settings.counterClass)
.insertAfter($input);
function updateCount() {
const remaining = settings.maxLength - $input.val().length;
$counter.text(remaining + ' characters remaining');
$counter.toggleClass('over-limit', remaining < 0);
}
$input.on('input', updateCount);
updateCount(); // show the correct count immediately, before any typing happens
});
};
}(jQuery));
<textarea id="bio" placeholder="Tell us about yourself"></textarea>
.char-counter { font-size: 0.85rem; color: #6b7280; margin-top: 0.25rem; }
.char-counter.over-limit { color: #dc2626; }
$('#bio').characterCounter({ maxLength: 150 });
Any option not supplied by the caller falls back to the corresponding default — calling $('#bio').characterCounter() with no arguments at all still works correctly, using the built-in 280-character default and the default counter class name.
A complete example, put together
<textarea id="tweet" maxlength="500"></textarea>
<textarea id="comment"></textarea>
(function ($) {
$.fn.characterCounter = function (options) {
const settings = $.extend({
maxLength: 280,
counterClass: 'char-counter',
}, options);
return this.each(function () {
const $input = $(this);
const $counter = $('<div>').addClass(settings.counterClass).insertAfter($input);
function updateCount() {
const remaining = settings.maxLength - $input.val().length;
$counter.text(remaining + ' characters remaining');
$counter.toggleClass('over-limit', remaining < 0);
}
$input.on('input', updateCount);
updateCount();
});
};
}(jQuery));
$('#tweet').characterCounter({ maxLength: 280 });
$('#comment').characterCounter({ maxLength: 1000, counterClass: 'char-counter comment-counter' });
Calling .characterCounter() on two entirely different textareas, each with its own options, demonstrates the whole point of writing it as a proper $.fn plugin in the first place: the same reusable behavior, safely applied to as many independent elements as needed, each configured differently, with no copy-pasted setup code.
Common mistakes
- Forgetting to
return this(orreturn this.each(...)) from a plugin function — it works fine on its own, but silently breaks chaining the moment someone tries to call another jQuery method right after it. - Not wrapping the plugin in an IIFE with
jQuerypassed in as$— on a page where another library has reassigned the global$(a real possibility when several old scripts coexist), the plugin can reference the wrong thing entirely. - Writing plugin logic that assumes exactly one matched element, instead of using
.each()— it appears to work in casual testing against a single element, then behaves incorrectly (usually only affecting the first matched element) the moment it's called against a selector matching several. - Mutating the caller's options object directly instead of merging it with
$.extend()into a fresh settings object — this can produce confusing bugs if the same options object is reused across multiple plugin calls elsewhere in the codebase.