I aim to create a tutorial on my users for the admin area. To achieve this, I'm using the admin pointers available in WP core. My goal:

enter image description here

I am almost there. What I got so far...

Enqueue wp-pointer scripts:

add_action( 'admin_enqueue_scripts', 'custom_admin_pointers_header' );

function custom_admin_pointers_header() {
    if ( custom_admin_pointers_check() ) {
        add_action( 'admin_print_footer_scripts', 'custom_admin_pointers_footer' );

        wp_enqueue_script( 'wp-pointer' );
        wp_enqueue_style( 'wp-pointer' );

Helper functions, including conditional check and footer script:

function custom_admin_pointers_check() {
    $admin_pointers = custom_admin_pointers();
    foreach ( $admin_pointers as $pointer => $array ) {
        if ( $array['active'] )
            return true;

function custom_admin_pointers_footer() {
    $admin_pointers = custom_admin_pointers();
    <script type="text/javascript">
        /* <![CDATA[ */
        ( function($) {
            foreach ( $admin_pointers as $pointer => $array ) {
               if ( $array['active'] ) {
            $( '<?php echo $array['anchor_id']; ?>' ).pointer( {
                content: '<?php echo $array['content']; ?>',
                position: {
                    edge: '<?php echo $array['edge']; ?>',
                    align: '<?php echo $array['align']; ?>'
                close: function() {
                    $.post( ajaxurl, {
                        pointer: '<?php echo $pointer; ?>',
                        action: 'dismiss-wp-pointer'
                    } );
            } ).pointer( 'open' );
        } )(jQuery);
        /* ]]> */

Now we are ready to put together the array of pointers:

function custom_admin_pointers() {
    $dismissed = explode( ',', (string) get_user_meta( get_current_user_id(), 'dismissed_wp_pointers', true ) );
    $version = '1_0'; // replace all periods in 1.0 with an underscore
    $prefix = 'custom_admin_pointers' . $version . '_';

    $new_pointer_content = '<h3>' . __( 'Add New Item' ) . '</h3>';
    $new_pointer_content .= '<p>' . __( 'Easily add a new post, media item, link, page or user by selecting from this drop down menu.' ) . '</p>';

    $story_pointer_content = '<h3>' . __( 'Another info' ) . '</h3>';
    $story_pointer_content .= '<p>' . __( 'Lorem ipsum...' ) . '</p>';

    return array(
        $prefix . 'new_items' => array(
            'content' => $new_pointer_content,
            'anchor_id' => '#wp-admin-bar-new-content',
            'edge' => 'top',
            'align' => 'left',
            'active' => ( ! in_array( $prefix . 'new_items', $dismissed ) )
        $prefix.'story_cover_help' => array(
            'content' => $story_pointer_content,
            'anchor_id' => '#save-post',
            'edge' => 'top',
            'align' => 'right',
            'active' => ( ! in_array( $prefix . 'story_cover_help', $dismissed ) )


The code is self-explanatory. We can easily add more pointers by extending the array. Everything works fine in WP4.

Now to the problem: All popup pointers appear at the same time, making this a poor interface for a tutorial.

My aim is to show the pointers one by one, and allow the user to click on a Next button to navigate through the tutorial. The next button should open the next pointer and close the last one.

How can I do this?

2 Answers 2


You are calling .pointer( 'open' ); javascript function on all the pointers objects, so is not a surprise that all pointers appear on same time...

That said, I don't understand why do you returns all pointers (even non-active ones) from custom_admin_pointers() and then add an additional function to check if there are some active pointers and a check inside pointers loop (if ( $array['active'] ) {) to choose to add javascript pointer or not. Is not simpler just returning only active pointers?

Moreover, you are adding that javascript on all admin pages, is not too much? Also consider that some elements like "#save-post" are available only on new post page, so is not better add the pointers only in new pot page?

Finally, how messy are that javascript mixed up with PHP, I think you should consider to use wp_localize_script to pass data to javascript.

The plan:

  1. Move the pointers definitions in PHP to a separate file, in this way it's easy to edit and also removing markup from PHP code, everything result more readable and maintenable
  2. In the pointers configuration add a property "where" that will be used to set in which admin page a popup should appear: post-new.php, index.php...
  3. Write a class that will handle the loading, the parsing and the filtering of pointers info
  4. Write some js goodness that will help us to change the default "Remove" button to "Next"

The #4 can (probably) done easily knowing the pointer plugin well, but it's not my case. So I'll use general jQuery code to obtain the result, if someone can improve my code I'll appreciate.


I edited the code (mainly js) because there are different things I had not considered: some pointers may be added to same anchor, or same pointers can be added to non-existing or non-visible anchors. In all that case previous code did'nt work, the new version seems to address that issues nicely.

I've also setup a Gist with all the code I used to test.

Let's start with points #1 and #2: create a file named pointers.php and write there:

$pointers = array();

$pointers['new-items'] = array(
  'title'     => sprintf( '<h3>%s</h3>', esc_html__( 'Add New Item' ) ),
  'content'   => sprintf( '<p>%s</p>', esc_html__( 'Easily add a new post..' ) ),
  'anchor_id' => '#wp-admin-bar-new-content',
  'edge'      => 'top',
  'align'     => 'left',
  'where'     => array( 'index.php', 'post-new.php' ) // <-- Please note this

$pointers['story_cover_help'] = array(
  'title'     => sprintf( '<h3>%s</h3>', esc_html__( 'Another info' ) ),
  'content'   => sprintf( '<p>%s</p>', esc_html__( 'Lore ipsum....' ) ),
  'anchor_id' => '#save-post',
  'edge'      => 'top',
  'align'     => 'right',
  'where'     => array( 'post-new.php' ) // <-- Please note this

// more pointers here...

return $pointers; 

All pointers configuration is here. When you need to change something, just open this file and edit it.

Note the "where" property that is an array of pages where the pointer should be available.

If you want to display pointers in a page generated by a plugin, look for this line outlined below public function filter( $page ) { and add die($page); immediately underneath it. Then open the respective plugin page and use that string in the where property.

Ok, now the point #3.

Before writing the class I just want to code an interface: there I'll put comments so that you can better understand what class will do.

interface PointersManagerInterface {

  * Load pointers from file and setup id with prefix and version.
  * Cast pointers to objects.
  public function parse();
  * Remove from parse pointers dismissed ones and pointers
  * that should not be shown on given page
  * @param string $page Current admin page file
  public function filter( $page );


I think should be pretty clear. Now let's write the class, it will contain the 2 methods from interface plus the constructor.

<?php namespace GM;
class PointersManager implements PointersManagerInterface {
  private $pfile;
  private $version;
  private $prefix;
  private $pointers = array();
  public function __construct( $file, $version, $prefix ) {
    $this->pfile = file_exists( $file ) ? $file : FALSE;
    $this->version = str_replace( '.', '_', $version );
    $this->prefix = $prefix;
  public function parse() {
    if ( empty( $this->pfile ) ) return;
    $pointers = (array) require_once $this->pfile;
    if ( empty($pointers) ) return;
    foreach ( $pointers as $i => $pointer ) {
      $pointer['id'] = "{$this->prefix}{$this->version}_{$i}";
      $this->pointers[$pointer['id']] = (object) $pointer;
  public function filter( $page ) {
    if ( empty( $this->pointers ) ) return array();
    $uid = get_current_user_id();
    $no = explode( ',', (string) get_user_meta( $uid, 'dismissed_wp_pointers', TRUE ) );
    $active_ids = array_diff( array_keys( $this->pointers ), $no );
    $good = array();
    foreach( $this->pointers as $i => $pointer ) {
      if (
        in_array( $i, $active_ids, TRUE ) // is active
        && isset( $pointer->where ) // has where
        && in_array( $page, (array) $pointer->where, TRUE ) // current page is in where
      ) {
       $good[] = $pointer;
    $count = count( $good );
    if ( $good === 0 ) return array();
    foreach( array_values( $good ) as $i => $pointer ) {
      $good[$i]->next = $i+1 < $count ? $good[$i+1]->id : '';
    return $good;

Code is very simple, and does exactly what interface expects.

However, the class does nothing by itself, we need an hook where to instantiate the class nad launch the 2 methods passing proper arguments.

The 'admin_enqueue_scripts' is perfect for our scope: there we will have access to current admin page and we can also enqueue scripts and styles needed.

add_action( 'admin_enqueue_scripts', function( $page ) {
  $file = plugin_dir_path( __FILE__ ) . 'pointers.php';
  // Arguments: pointers php file, version (dots will be replaced), prefix
  $manager = new PointersManager( $file, '5.0', 'custom_admin_pointers' );
  $pointers = $manager->filter( $page );
  if ( empty( $pointers ) ) { // nothing to do if no pointers pass the filter
  wp_enqueue_style( 'wp-pointer' );
  $js_url = plugins_url( 'pointers.js', __FILE__ );
  wp_enqueue_script( 'custom_admin_pointers', $js_url, array('wp-pointer'), NULL, TRUE );
  // data to pass to javascript
  $data = array(
    'next_label' => __( 'Next' ),
    'close_label' => __('Close'),
    'pointers' => $pointers
  wp_localize_script( 'custom_admin_pointers', 'MyAdminPointers', $data );
} );

Nothing special: just using the class to get pointers data and if some pointers pass the filters enqueue styles and scripts. Then pass pointers data to script along to the localized "Next" label for the button.

Ok, now the "hardest" part: the js. Again I want to highlight that I don't know the pointer plugin WordPress uses, so what I do in my code can be done better if someone knows it, however my code do its work and -genarally speaking- it's not so bad.

( function($, MAP) {

  $(document).on( 'MyAdminPointers.setup_done', function( e, data ) {
    MAP.setPlugin( data ); // open first popup
  } );
  $(document).on( 'MyAdminPointers.current_ready', function( e ) {
    MAP.openPointer(); // open a popup
  } );
  MAP.js_pointers = {};        // contain js-parsed pointer objects
  MAP.first_pointer = false;   // contain first pointer anchor jQuery object
  MAP.current_pointer = false; // contain current pointer jQuery object
  MAP.last_pointer = false;    // contain last pointer jQuery object
  MAP.visible_pointers = [];   // contain ids of pointers whose anchors are visible
  MAP.hasNext = function( data ) { // check if a given pointer has valid next property
    return typeof data.next === 'string'
      && data.next !== ''
      && typeof MAP.js_pointers[data.next].data !== 'undefined'
      && typeof MAP.js_pointers[data.next].data.id === 'string';
  MAP.isVisible = function( data ) { // check if anchor for given pointer is visible
    return $.inArray( data.id, MAP.visible_pointers ) !== -1;
  // given a pointer object, return its the anchor jQuery object if available
  // otherwise return first available, lookin at next property of subsequent pointers
  MAP.getPointerData = function( data ) { 
    var $target = $( data.anchor_id );
    if ( $.inArray(data.id, MAP.visible_pointers) !== -1 ) {
      return { target: $target, data: data };
    $target = false;
    while( MAP.hasNext( data ) && ! MAP.isVisible( data ) ) {
      data = MAP.js_pointers[data.next].data;
      if ( MAP.isVisible( data ) ) {
        $target = $(data.anchor_id);
    return MAP.isVisible( data )
      ? { target: $target, data: data }
      : { target: false, data: false };
  // take pointer data and setup pointer plugin for anchor element
  MAP.setPlugin = function( data ) {
    if ( typeof MAP.last_pointer === 'object') {
      MAP.last_pointer = false;
    MAP.current_pointer = false;
    var pointer_data = MAP.getPointerData( data );
      if ( ! pointer_data.target || ! pointer_data.data ) {
    $target = pointer_data.target;
    data = pointer_data.data;
    $pointer = $target.pointer({
      content: data.title + data.content,
      position: { edge: data.edge, align: data.align },
      close: function() {
        // open next pointer if it exists
        if ( MAP.hasNext( data ) ) {
          MAP.setPlugin( MAP.js_pointers[data.next].data );
        $.post( ajaxurl, { pointer: data.id, action: 'dismiss-wp-pointer' } );
    MAP.current_pointer = { pointer: $pointer, data: data };
    $(document).trigger( 'MyAdminPointers.current_ready' );
  // scroll the page to current pointer then open it
  MAP.openPointer = function() {          
    var $pointer = MAP.current_pointer.pointer;
    if ( ! typeof $pointer === 'object' ) {
    $('html, body').animate({ // scroll page to pointer
      scrollTop: $pointer.offset().top - 30
    }, 300, function() { // when scroll complete
      MAP.last_pointer = $pointer;
        var $widget = $pointer.pointer('widget');
        MAP.setNext( $widget, MAP.current_pointer.data );
        $pointer.pointer( 'open' ); // open
  // if there is a next pointer set button label to "Next", to "Close" otherwise
  MAP.setNext = function( $widget, data ) {
    if ( typeof $widget === 'object' ) {
      var $buttons = $widget.find('.wp-pointer-buttons').eq(0);        
      var $close = $buttons.find('a.close').eq(0);
      $button = $close.clone(true, true).removeClass('close');
      has_next = false;
      if ( MAP.hasNext( data ) ) {
        has_next_data = MAP.getPointerData(MAP.js_pointers[data.next].data);
        has_next = has_next_data.target && has_next_data.data;
      var label = has_next ? MAP.next_label : MAP.close_label;
  $(MAP.pointers).each(function(index, pointer) { // loop pointers data
    if( ! $().pointer ) return;      // do nothing if pointer plugin isn't available
    MAP.js_pointers[pointer.id] = { data: pointer };
    var $target = $(pointer.anchor_id);
    if ( $target.length && $target.is(':visible') ) { // anchor exists and is visible?
      if ( ! MAP.first_pointer ) {
        MAP.first_pointer = pointer;
    if ( index === ( MAP.pointers.length - 1 ) && MAP.first_pointer ) {
      $(document).trigger( 'MyAdminPointers.setup_done', MAP.first_pointer );

} )(jQuery, MyAdminPointers); // MyAdminPointers is passed by `wp_localize_script`

With help of comments the code should be pretty clear, at least, I hope so.

Ok we are done. Our PHP is simpler and better organized, our javascript more readable, pointers are easier to edit and, more important, everything works.

  • 1
    @ChristineCooper sure. Ok, problems are 2: 1st for how the script works now, you can add 1 pointer for 1 anchor id: using same anchor for more than one pointer will cause the script fail. 2nd problem is that some pointers use anchor to ids that may not be in the page. E.g. one pointer is for '#comment-55' in index.php, and it isn't found. Some pointers in post.php target metaboxes that may be hidden... and so on. Once in current version of the script pointers are "chained" if one is not found all subsequents will not work too. I'm going to see if there is a simple way to overcome those issues.
    – gmazzap
    Sep 29, 2014 at 17:05
  • 1
    @ChristineCooper Glad it worked. I'll copy all code from Gist back here. Condition can be put just after add_action( 'admin_enqueue_scripts', function( $page ) { simply returning if user has no required role.
    – gmazzap
    Sep 30, 2014 at 20:05
  • Please change the 30 value to 120 on line: "scrollTop: $pointer.offset().top - 30" -- The reason is because the top toolbar covers the pointer window occasionally when scrolling.
    – Christine Cooper
    Oct 3, 2014 at 16:05
  • I have one minor problem. The page I need some pointers to appear is: "admin.php?page=plugin-path/file.php" - what exactly do I add to the where array? I tried "admin.php", "plugin-path/file.php", "file.php" and any variation I could think of. Is there a reason why it can't detect this page or am I doing this wrong?
    – Christine Cooper
    Oct 4, 2014 at 15:16
  • 1
    @ChristineCooper open the plugin admin page and copy the url from browser. After that, open the file that contain my code above. Find the line public function filter( $page ) { in PointersManager class, and immediately after that line put die($page);. Open your browser and paste back the url, the page will die with a string: that's the what you have to use as 'where'.
    – gmazzap
    Oct 4, 2014 at 19:25

Ahhh.. yes. WordPress pointers. You know, there are quite a lot of mixed feelings when it comes to using pointers ;)

You were on the right track with your code above. But there are a couple issues.

@G.M. is correct about the pointer('open') command opening all your pointers at once. Furthermore, you are not providing a method to advance through pointers.

I fought this same issue.. and came up with my own approach. I use a query variable in the url, reload the page to the admin page where I want to display the next pointer, and let jQuery handle the rest.

WP Pointers Class

I decided to write this as a class. But I'm going to show it in increments at first to help you better understand what is happening.

Beginning the Class

// Create as a class
class testWPpointers {

    // Define pointer version
    const DISPLAY_VERSION = 'v1.0';

    // Initiate construct
    function __construct () {
        add_action('admin_enqueue_scripts', array($this, 'admin_enqueue_scripts'));  // Hook to admin_enqueue_scripts

    function admin_enqueue_scripts () {
        // Check to see if user has already dismissed the pointer tour
        $dismissed = explode (',', get_user_meta (wp_get_current_user ()->ID, 'dismissed_wp_pointers', true));
        $do_tour = !in_array ('test_wp_pointer', $dismissed);
        // If not, we are good to continue
        if ($do_tour) {
            // Enqueue necessary WP scripts and styles
            wp_enqueue_style ('wp-pointer');
            wp_enqueue_script ('wp-pointer');
            // Finish hooking to WP admin areas
            add_action('admin_print_footer_scripts', array($this, 'admin_print_footer_scripts'));  // Hook to admin footer scripts
            add_action('admin_head', array($this, 'admin_head'));  // Hook to admin head

    // Used to add spacing between the two buttons in the pointer overlay window.
    function admin_head () {
        <style type="text/css" media="screen">
            #pointer-primary {
                margin: 0 5px 0 0;
  1. We have defined the class.
  2. We constructed the class, and added an action to admin_enqueue_scripts.
  3. We determined if our pointers have already been dismissed.
  4. If not, we continue to enqueue the necessary scripts.

You do NOT need to change anything in these first functions.

Setting up the array of Pointer Items

The next step is defining each of the pointers. There are five items we need to define (excpept for the last pointer). We will do this using arrays. Let's take a look at the function:

// Define footer scripts
function admin_print_footer_scripts () {
    // Define global variables
    global $pagenow;
    global $current_user;
    // This is our array of individual pointers.
    // -- The array key should be unique.  It is what will be used to 'advance' to the next pointer.
    // -- The 'id' should correspond to an html element id on the page.
    // -- The 'content' will be displayed inside the pointer overlay window.
    // -- The 'button2' is the text to show for the 'action' button in the pointer overlay window.
    // -- The 'function' is the method used to reload the window (or relocate to a new window).
    //    This also creates a query variable to add to the end of the url.
    //    The query variable is used to determine which pointer to display.
    $tour = array (
        'quick_press' => array (
            'id' => '#dashboard_quick_press',
            'content' => '<h3>' . __('Congratulations!', 'test_lang') . '</h3>'
                . '<p><strong>' . __('WP Pointers is working properly.', 'test_lang') . '</strong></p>'
                . '<p>' . __('This pointer is attached to the "Quick Draft" admin widget.', 'test_lang') . '</p>'
                . '<p>' . __('Our next pointer will take us to the "Settings" admin menu.', 'test_lang') . '</p>',
            'button2' => __('Next', 'test_lang'),
            'function' => 'window.location="' . $this->get_admin_url('options-general.php', 'site_title') . '"'  // We are relocating to "Settings" page with the 'site_title' query var
        'site_title' => array (
            'id' => '#blogname',
            'content' => '<h3>' . __('Moving along to Site Title.', 'test_lang') . '</h3>'
            . '<p><strong>' . __('Another WP Pointer.', 'test_lang') . '</strong></p>'
            . '<p>' . __('This pointer is attached to the "Blog Title" input field.', 'test_lang') . '</p>',
            'button2' => __('Next', 'test_lang'),
            'function' => 'window.location="' . $this->get_admin_url('index.php', 'quick_press_last') . '"'  // We are relocating back to "Dashboard" with 'quick_press_last' query var
        'quick_press_last' => array (
            'id' => '#dashboard_quick_press',
            'content' => '<h3>' . __('This concludes our WP Pointers tour.', 'test_lang') . '</h3>'
            . '<p><strong>' . __('Last WP Pointer.', 'test_lang') . '</strong></p>'
            . '<p>' . __('When closing the pointer tour; it will be saved in the users custom meta.  The tour will NOT be shown to that user again.', 'test_lang') . '</p>'
    // Determine which tab is set in the query variable
    $tab = isset($_GET['tab']) ? $_GET['tab'] : '';
    // Define other variables
    $function = '';
    $button2 = '';
    $options = array ();
    $show_pointer = false;

    // *******************************************************************************************************
    // This will be the first pointer shown to the user.
    // If no query variable is set in the url.. then the 'tab' cannot be determined... and we start with this pointer.
    // *******************************************************************************************************
    if (!array_key_exists($tab, $tour)) {
        $show_pointer = true;
        $file_error = true;
        $id = '#dashboard_right_now';  // Define ID used on page html element where we want to display pointer
        $content = '<h3>' . sprintf (__('Test WP Pointers %s', 'test_lang'), self::DISPLAY_VERSION) . '</h3>';
        $content .= __('<p>Welcome to Test WP Pointers admin tour!</p>', 'test_lang');
        $content .= __('<p>This pointer is attached to the "At a Glance" dashboard widget.</p>', 'test_lang');
        $content .= '<p>' . __('Click the <em>Begin Tour</em> button to get started.', 'test_lang' ) . '</p>';

        $options = array (
            'content' => $content,
            'position' => array ('edge' => 'top', 'align' => 'left')
        $button2 = __('Begin Tour', 'test_lang' );
        $function = 'document.location="' . $this->get_admin_url('index.php', 'quick_press') . '";';
    // Else if the 'tab' is set in the query variable.. then we can determine which pointer to display
    else {
        if ($tab != '' && in_array ($tab, array_keys ($tour))) {
            $show_pointer = true;
            if (isset ($tour[$tab]['id'])) {
                $id = $tour[$tab]['id'];
            $options = array (
                'content' => $tour[$tab]['content'],
                'position' => array ('edge' => 'top', 'align' => 'left')
            $button2 = false;
            $function = '';
            if (isset ($tour[$tab]['button2'])) {
                $button2 = $tour[$tab]['button2'];
            if (isset ($tour[$tab]['function'])) {
                $function = $tour[$tab]['function'];
    // If we are showing a pointer... let's load the jQuery.
    if ($show_pointer) {
        $this->make_pointer_script ($id, $options, __('Close', 'test_lang'), $button2, $function);

Okay.. let's take a look at a few things here.

First, our $tour array. This is the array that holds all the pointers EXCEPT the first pointer that is displayed to the user (more on this later). So, you'll want to start with the second pointer you intend to show.. and continue through to the last pointer.

Next, we have a few items which are very important.

  1. The $tour array keys must be unique (quick_press, site_title, quick_press_last; as examples above).
  2. The 'id' command MUST match the html element id of the item you wish to attach to the pointer.
  3. The function command will reload/relocate the window. This is what is used to show the next pointer. We have to either reload the window, or relocate it to the next admin page where a pointer will be displayed.
  4. We run the get_admin_url() function with two variables; the first is the admin page where we want to go next; and the second is the unique array key of the pointer we wish to display.

Further down, you'll see the code that begins if (!array_key_exists($tab, $tour)) {. This is where we determine if a url query variable has been set. If it has NOT, then we need to define the first pointer to display.

This pointer uses the exact same id, content, button2, and function items as used in our $tour array above. Remember, the second argument of the get_admin_url() function MUST be the exact same as the array key in the $tour variable. This is what tells the script to go to the next pointer.

The rest of the function is used if a query variable is already set in the url. There is no need to adjust any more of the function.

Getting Admin Url The next function is actually a helper function... used to get the admin url and advance the pointer.

// This function is used to reload the admin page.
// -- $page = the admin page we are passing (index.php or options-general.php)
// -- $tab = the NEXT pointer array key we want to display
function get_admin_url($page, $tab) {
    $url = admin_url();
    $url .= $page.'?tab='.$tab;

    return $url;

Remember, there are two arguments; the admin page we are going to.. and the tab. The tab will be the $tour array key we want to go to next. THESE MUST MATCH.

So, when we call the function get_admin_url() and pass the two variables; the first variable determines the next admin page.. and the second variable determines which pointer to display.

Lastly... we can finally print the admin script to the footer.

// Print footer scripts
function make_pointer_script ($id, $options, $button1, $button2=false, $function='') {
    <script type="text/javascript">
        (function ($) {
            // Define pointer options
            var wp_pointers_tour_opts = <?php echo json_encode ($options); ?>, setup;
            wp_pointers_tour_opts = $.extend (wp_pointers_tour_opts, {
                // Add 'Close' button
                buttons: function (event, t) {
                    button = jQuery ('<a id="pointer-close" class="button-secondary">' + '<?php echo $button1; ?>' + '</a>');
                    button.bind ('click.pointer', function () {
                        t.element.pointer ('close');
                    return button;
                close: function () {
                    // Post to admin ajax to disable pointers when user clicks "Close"
                    $.post (ajaxurl, {
                        pointer: 'test_wp_pointer',
                        action: 'dismiss-wp-pointer'
            // This is used for our "button2" value above (advances the pointers)
            setup = function () {
                $('<?php echo $id; ?>').pointer(wp_pointers_tour_opts).pointer('open');
                <?php if ($button2) { ?>
                    jQuery ('#pointer-close').after ('<a id="pointer-primary" class="button-primary">' + '<?php echo $button2; ?>' + '</a>');
                    jQuery ('#pointer-primary').click (function () {
                        <?php echo $function; ?>  // Execute button2 function
                    jQuery ('#pointer-close').click (function () {
                        // Post to admin ajax to disable pointers when user clicks "Close"
                        $.post (ajaxurl, {
                            pointer: 'test_wp_pointer',
                            action: 'dismiss-wp-pointer'
                <?php } ?>
            if (wp_pointers_tour_opts.position && wp_pointers_tour_opts.position.defer_loading) {
                $(window).bind('load.wp-pointers', setup);
            else {
                setup ();
        }) (jQuery);
$testWPpointers = new testWPpointers();

Again, there is no need to change anything above. This script will define and output the two buttons in the pointer overlay window. One will always be the "Close" button; and will update the current user meta dismissed_pointers option.

The second button (the action button) will execute the function (our window relocation method).

And we close the class.

Here is the code in it's entirety. WP Pointer Class

You can copy/paste that into your dev site and visit the "Dashboard" page. It will guide you through the tour.

Remember, it's a little confusing that the first pointer is defined last in the code. That is the way it is supposed to work. The array will hold all the rest of the pointers you wish to use.

Remember, the 'id' array item MUST match the second argument of the get_admin_url() function from the previous array item 'function' command. This is how the pointers 'talk' to each other and know how to advance.

Enjoy!! :)

  • This is lovely Josh, thank you very much! I'll try this out and see how well it functions. I should highlight that GM's code is the one that I will likely award this bounty because it has a few essential features that I requested and which I believe is important to create a guide, particularly for multiple pages in wp-admin. Nonetheless, it is great to see another approach at this and will come in handy for other users who are looking for a good solution. Out of curiosity, you said there are quite a lot of mixed feelings when it comes to using pointers, care to elaborate?
    – Christine Cooper
    Sep 30, 2014 at 13:53
  • 2
    No worries :) Well, pointers can 'get in the way' when used in excess. No one wants to visit a page and have three or four pointers display... especially if they are unrelated. Say two other plugins are showing pointers, then we add more pointers.. it can become overkill. Most people say to use them sparingly... but to each is their own :) Glad you got it working properly.
    – josh
    Sep 30, 2014 at 18:34
  • 1
    This is also awesome Josh, I'd make 1 suggestion, to make this more flexible and have it where you can simply pass in the array into a public function instead of storing that inside the class code itself. Second, the first pointer, how it is separate, modify it so that it could be the first array key/value in the pointer array. Just a couple ideas so this class could be called from another script and simply passed in the array of pointer. I still really like it, thanks for sharing i'll be using this!
    – JasonDavis
    Oct 18, 2014 at 1:13
  • Thanks @jasondavis. I actually pulled that code from another plugin I developed for someone. I was only interested in getting it working properly. But yes, I absolutely agree with you... it needs to be cleaned. Perhaps I'll stop by later today and mess with it again :) You rock, bro!
    – josh
    Oct 18, 2014 at 19:41
  • It;s cool, I actually never had any intention of using the Admin Pointers, mainly because they looked like a nightmare and 2nd because I have no real use for them, but your class makes them look so easy to use now that I feel I have to use them with it being so easy with that class! I love little projects/libraries like that, good stuff
    – JasonDavis
    Oct 18, 2014 at 22:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.