Himanshu SharmaSoftware engineer
Let’s talk

jQuery · Lesson 40 of 45

Writing a jQuery plugin

Write your own jQuery plugin with 1fn, keep it chainable, take options with defaults, store state with .data(), namespace events and add a destroy method.

13 minUtilities and pluginsUpdated 23 October 2026

By the end of this lessonPackage reusable behaviour as a jQuery plugin that works on any selection, is configurable, can be removed cleanly and does not clash with other code.

What a plugin is

A jQuery plugin is just a function added to $.fn. $.fn is the shared prototype of every jQuery object, so once you add a function there, every selection has it, exactly like .hide() or .addClass():

html
<p>One</p><p>Two</p>

<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script>
  $.fn.shout = function () {
    return this.each(function () {
      $(this).text($(this).text().toUpperCase() + "!");
    });
  };

  $("p").shout().css("color", "crimson");
  console.log($("p").text());
</script>
result
ONE!TWO!

Two rules make it feel like a real jQuery method:

  1. Inside the plugin, this is the jQuery object the method was called on, which may hold many elements, so loop with this.each().
  2. Return this (which this.each() does for you), so chaining continues: .shout().css(...).

A real plugin, step by step

Let's build a character counter for text fields: $("textarea").charCounter({ max: 140 }).

html
<textarea id="tweet" data-max="20" rows="2" cols="40"></textarea>
<textarea id="bio" rows="2" cols="40"></textarea>

<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script>
  (function ($) {
    "use strict";

    $.fn.charCounter = function (options) {
      if (options === "destroy") {
        return this.each(function () {
          const $input = $(this);
          $input.off(".charCounter");
          $input.data("charCounter")?.$label.remove();
          $input.removeData("charCounter");
        });
      }

      return this.each(function () {
        const $input = $(this);
        if ($input.data("charCounter")) return;          // already set up

        // Defaults, then options passed in, then this element's own data- attributes
        const settings = $.extend({}, $.fn.charCounter.defaults, options, $input.data());
        const $label = $("<small>", { "class": settings.className, "aria-live": "polite" }).insertAfter($input);

        function update() {
          const left = settings.max - $input.val().length;
          $label.text(left + " characters left").toggleClass("over", left < 0);
          $input.trigger("charcounter:update", [left]);
        }

        $input.on("input.charCounter", update);
        $input.data("charCounter", { settings, $label });
        update();
      });
    };

    // Public defaults, so a site can change them once for every counter
    $.fn.charCounter.defaults = { max: 100, className: "char-counter" };
  })(jQuery);

  $("textarea").charCounter({ max: 50 });
  $("textarea").on("charcounter:update", function (event, left) {
    console.log(this.id, "->", left, "left");
  });
</script>
result
tweet -> 19 left
tweet -> 18 left
tweet -> 17 left
tweet -> 16 left
tweet -> 15 left
bio -> 49 left
bio -> 48 left

The two counters use different limits: #tweet has data-max="20" in its HTML, which overrides the max: 50 passed in, while #bio uses 50. Data attributes come last in the $.extend call, so the most specific setting, the one on the element itself, wins.

Let's look at each part.

The wrapper

javascript
(function ($) {
  // plugin code
})(jQuery);

This function runs immediately and receives jQuery as a parameter named $. On sites where $ belongs to another library or has been released with noConflict (as in WordPress), the plugin still works. It also keeps your helper variables private.

Options and defaults

$.extend({}, $.fn.charCounter.defaults, options, $input.data()) builds the settings: defaults first, then what the caller passed, then anything set in the element's data- attributes. Exposing the defaults as $.fn.charCounter.defaults lets a site change them once for every counter:

javascript
$.fn.charCounter.defaults.max = 280;

State and the double-call guard

The plugin stores its state with .data("charCounter", ...). That serves two purposes: the destroy action can find the label to remove, and calling .charCounter() twice on the same element does nothing the second time, instead of adding a second label and a second set of handlers.

Namespaced events

The handler is attached as input.charCounter. The destroy action calls .off(".charCounter"), which removes only the plugin's own handlers and leaves any the page added.

Announcing changes

$input.trigger("charcounter:update", [left]) fires a custom event, so the page can react (for example, disable a submit button) without the plugin knowing anything about it.

Calling a method on an existing plugin

The common pattern for "methods" is to pass a string, as destroy does above:

html
<textarea id="t"></textarea>

<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script>
  $.fn.charCounter = function (options) {
    if (options === "destroy") {
      return this.each(function () {
        $(this).off(".charCounter").data("charCounter")?.$label.remove();
        $(this).removeData("charCounter");
      });
    }
    return this.each(function () {
      if ($(this).data("charCounter")) return;
      const $label = $("<small>").insertAfter(this);
      $(this).on("input.charCounter", () => $label.text($(this).val().length)).data("charCounter", { $label });
    });
  };

  $("#t").charCounter().charCounter();          // second call is ignored
  console.log("labels after two calls:", $("small").length);
  $("#t").charCounter("destroy");
  console.log("labels after destroy:", $("small").length, "| data left:", $("#t").data("charCounter"));
</script>
result
labels after two calls: 1
labels after destroy: 0 | data left: undefined

A destroy method matters in single-page interfaces and dashboards, where parts of the page are replaced without a reload: removing the plugin cleanly prevents leaked handlers and duplicate behaviour.

Utility plugins

A plugin can also add a function to $ itself instead of $.fn, for tools that are not about elements:

javascript
$.formatPrice = function (amount, currency = "INR") {
  return new Intl.NumberFormat("en-IN", { style: "currency", currency }).format(amount);
};

Keep these few. A plain JavaScript module or function is usually just as good and does not depend on jQuery.

Using other people's plugins

Thousands of jQuery plugins exist: date pickers, sliders, carousels, validation. Before adding one, check that it is maintained, works with jQuery 4 (old plugins often call removed functions such as $.isArray), is accessible with a keyboard, and is worth its download size. Load plugins after jQuery and before the code that uses them.

Common mistakes

  • Forgetting return, which breaks chaining.
  • Working on this[0] only, so the plugin ignores all but the first element.
  • Changing the shared defaults with $.extend(defaults, options) instead of $.extend({}, defaults, options).
  • No guard against double initialisation.
  • Un-namespaced events that the plugin cannot remove safely.

Exercise

Write a plugin $.fn.maxHeight that sets every selected element to the height of the tallest one, and returns the selection for chaining. Test it on three boxes of different heights.

Show one possible answer
html
<div class="col" style="height: 40px">A</div>
<div class="col" style="height: 90px">B</div>
<div class="col" style="height: 60px">C</div>

<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script>
  (function ($) {
    $.fn.maxHeight = function () {
      const tallest = Math.max(...this.map((i, el) => $(el).height()).get());
      return this.height(tallest);
    };
  })(jQuery);

  $(".col").maxHeight().css("background", "#e0f2fe");
  console.log($(".col").map((i, el) => $(el).height()).get());
</script>
result
[90, 90, 90]

Key points

  • A plugin is a function added to $.fn, so every jQuery object gets it: $("input").charCounter().
  • Loop with this.each() and return the result, so the plugin works on many elements and stays chainable.
  • Merge options with public defaults using $.extend({}, defaults, options).
  • Store state with .data(), guard against running twice, namespace events, and provide a destroy action.

Check yourself

1. Where do you add a plugin method so all jQuery objects have it?
2. Why should a plugin end with return this.each(...)?
3. What is the purpose of wrapping a plugin in (function ($) { ... })(jQuery)?
4. Why namespace a plugin's events, for example input.charCounter?