diff --git a/.eslintrc.js b/.eslintrc.js
index eba7eb33f..6319501e3 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -234,6 +234,11 @@ const appConfig = {
                 prev: '*',
                 next: 'return',
             },
+            {
+                blankLine: 'always',
+                prev: '*',
+                next: 'function',
+            },
         ],
         'prefer-arrow/prefer-arrow-functions': [
             'error',
diff --git a/src/core/directives/directives.module.ts b/src/core/directives/directives.module.ts
index 3a89d007f..d004ce3cd 100644
--- a/src/core/directives/directives.module.ts
+++ b/src/core/directives/directives.module.ts
@@ -32,8 +32,8 @@ import { CoreSwipeNavigationDirective } from './swipe-navigation';
 import { CoreCollapsibleItemDirective } from './collapsible-item';
 import { CoreCollapsibleFooterDirective } from './collapsible-footer';
 import { CoreContentDirective } from './content';
-import { CoreOnAppearDirective } from './on-appear';
 import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-attributes';
+import { CoreUserTourDirective } from './user-tour';
 
 @NgModule({
     declarations: [
@@ -48,7 +48,6 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
         CoreSupressEventsDirective,
         CoreUserLinkDirective,
         CoreAriaButtonClickDirective,
-        CoreOnAppearDirective,
         CoreOnResizeDirective,
         CoreDownloadFileDirective,
         CoreCollapsibleHeaderDirective,
@@ -57,6 +56,7 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
         CoreCollapsibleFooterDirective,
         CoreContentDirective,
         CoreUpdateNonReactiveAttributesDirective,
+        CoreUserTourDirective,
     ],
     exports: [
         CoreAutoFocusDirective,
@@ -70,7 +70,6 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
         CoreSupressEventsDirective,
         CoreUserLinkDirective,
         CoreAriaButtonClickDirective,
-        CoreOnAppearDirective,
         CoreOnResizeDirective,
         CoreDownloadFileDirective,
         CoreCollapsibleHeaderDirective,
@@ -79,6 +78,7 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
         CoreCollapsibleFooterDirective,
         CoreContentDirective,
         CoreUpdateNonReactiveAttributesDirective,
+        CoreUserTourDirective,
     ],
 })
 export class CoreDirectivesModule {}
diff --git a/src/core/directives/swipe-navigation.ts b/src/core/directives/swipe-navigation.ts
index ee2f6c6a1..b2aa69c6b 100644
--- a/src/core/directives/swipe-navigation.ts
+++ b/src/core/directives/swipe-navigation.ts
@@ -93,6 +93,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy {
         await CoreUserTours.showIfPending({
             id: 'swipe-navigation',
             component: CoreSwipeNavigationTourComponent,
+            watch: this.element,
         });
     }
 
diff --git a/src/core/directives/on-appear.ts b/src/core/directives/user-tour.ts
similarity index 50%
rename from src/core/directives/on-appear.ts
rename to src/core/directives/user-tour.ts
index 4dff653c9..04d3636ea 100644
--- a/src/core/directives/on-appear.ts
+++ b/src/core/directives/user-tour.ts
@@ -12,20 +12,22 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { Directive, ElementRef, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
+import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core';
 import { CoreCancellablePromise } from '@classes/cancellable-promise';
+import { CoreUserTours, CoreUserToursFocusedOptions, CoreUserToursUserTour } from '@features/usertours/services/user-tours';
 import { CoreDom } from '@singletons/dom';
 
 /**
- * Directive to listen when an element becomes visible.
+ * Directive to control a User Tour linked to the lifecycle of the element where it's defined.
  */
 @Directive({
-    selector: '[onAppear]',
+    selector: '[userTour]',
 })
-export class CoreOnAppearDirective implements OnInit, OnDestroy {
+export class CoreUserTourDirective implements OnInit, OnDestroy {
 
-    @Output() onAppear = new EventEmitter();
+    @Input() userTour!: CoreUserTourDirectiveOptions;
 
+    private tour?: CoreUserToursUserTour | null;
     private element: HTMLElement;
     protected visiblePromise?: CoreCancellablePromise<void>;
 
@@ -41,14 +43,35 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy {
 
         await this.visiblePromise;
 
-        this.onAppear.emit();
+        const { getFocusedElement, ...options } = this.userTour;
+
+        this.tour = await CoreUserTours.showIfPending({
+            ...options,
+            focus: getFocusedElement?.(this.element) ?? this.element,
+        });
     }
 
     /**
      * @inheritdoc
      */
     ngOnDestroy(): void {
+        this.tour?.cancel();
         this.visiblePromise?.cancel();
     }
 
 }
+
+/**
+ * User Tour options to control with this directive.
+ */
+export type CoreUserTourDirectiveOptions = Omit<CoreUserToursFocusedOptions, 'focus'> & {
+
+    /**
+     * Getter to obtain element to focus in the User Tour. If this isn't provided, the element where the
+     * directive is defined will be used.
+     *
+     * @param element Element where the directive is defined.
+     */
+    getFocusedElement?(element: HTMLElement): HTMLElement;
+
+};
diff --git a/src/core/features/block/components/side-blocks-button/side-blocks-button.html b/src/core/features/block/components/side-blocks-button/side-blocks-button.html
index 13cd1b7fe..69fdcdf8f 100644
--- a/src/core/features/block/components/side-blocks-button/side-blocks-button.html
+++ b/src/core/features/block/components/side-blocks-button/side-blocks-button.html
@@ -1,4 +1,4 @@
-<ion-button (click)="openBlocks()" (onAppear)="showTour()" [attr.aria-label]="'core.block.opendrawerblocks' | translate" color="secondary"
+<ion-button (click)="openBlocks()" [userTour]="userTour" [attr.aria-label]="'core.block.opendrawerblocks' | translate" color="secondary"
     #button>
     <ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
 </ion-button>
diff --git a/src/core/features/block/components/side-blocks-button/side-blocks-button.ts b/src/core/features/block/components/side-blocks-button/side-blocks-button.ts
index 3bec41f02..03efee0ae 100644
--- a/src/core/features/block/components/side-blocks-button/side-blocks-button.ts
+++ b/src/core/features/block/components/side-blocks-button/side-blocks-button.ts
@@ -14,7 +14,8 @@
 
 import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
 import { CoreCancellablePromise } from '@classes/cancellable-promise';
-import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
+import { CoreUserTourDirectiveOptions } from '@directives/user-tour';
+import { CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
 import { CoreDomUtils } from '@services/utils/dom';
 import { CoreDom } from '@singletons/dom';
 import { CoreBlockSideBlocksTourComponent } from '../side-blocks-tour/side-blocks-tour';
@@ -34,6 +35,14 @@ export class CoreBlockSideBlocksButtonComponent implements OnInit, OnDestroy {
     @Input() instanceId!: number;
     @ViewChild('button', { read: ElementRef }) button?: ElementRef<HTMLElement>;
 
+    userTour: CoreUserTourDirectiveOptions = {
+        id: 'side-blocks-button',
+        component: CoreBlockSideBlocksTourComponent,
+        side: CoreUserToursSide.Start,
+        alignment: CoreUserToursAlignment.Center,
+        getFocusedElement: nativeButton => nativeButton.shadowRoot?.children[0] as HTMLElement,
+    };
+
     protected element: HTMLElement;
     protected slotPromise?: CoreCancellablePromise<void>;
 
@@ -61,25 +70,6 @@ export class CoreBlockSideBlocksButtonComponent implements OnInit, OnDestroy {
         });
     }
 
-    /**
-     * Show User Tour.
-     */
-    async showTour(): Promise<void> {
-        const nativeButton = this.button?.nativeElement.shadowRoot?.children[0] as HTMLElement;
-
-        if (!nativeButton) {
-            return;
-        }
-
-        await CoreUserTours.showIfPending({
-            id: 'side-blocks-button',
-            component: CoreBlockSideBlocksTourComponent,
-            focus: nativeButton,
-            side: CoreUserToursSide.Start,
-            alignment: CoreUserToursAlignment.Center,
-        });
-    }
-
     /**
      * @inheritdoc
      */
diff --git a/src/core/features/course/components/course-format/course-format.html b/src/core/features/course/components/course-format/course-format.html
index 323c1dbfc..4b86d5ce7 100644
--- a/src/core/features/course/components/course-format/course-format.html
+++ b/src/core/features/course/components/course-format/course-format.html
@@ -55,7 +55,7 @@
 
 <!-- Course Index button. -->
 <ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && displayCourseIndex">
-    <ion-fab-button (click)="openCourseIndex()" (onAppear)="showCourseIndexTour()" [attr.aria-label]="'core.course.courseindex' | translate"
+    <ion-fab-button (click)="openCourseIndex()" [userTour]="courseIndexTour" [attr.aria-label]="'core.course.courseindex' | translate"
         color="secondary" #courseIndexFab>
         <ion-icon name="fas-list-ul" aria-hidden="true"></ion-icon>
         <span class="sr-only">{{'core.course.courseindex' | translate }}</span>
diff --git a/src/core/features/course/components/course-format/course-format.ts b/src/core/features/course/components/course-format/course-format.ts
index c1bee68f6..ca8f8b29c 100644
--- a/src/core/features/course/components/course-format/course-format.ts
+++ b/src/core/features/course/components/course-format/course-format.ts
@@ -46,9 +46,10 @@ import { CoreBlockHelper } from '@features/block/services/block-helper';
 import { CoreNavigator } from '@services/navigator';
 import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
 import { CoreCourseViewedModulesDBRecord } from '@features/course/services/database/course';
-import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
+import { CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
 import { CoreCourseCourseIndexTourComponent } from '../course-index-tour/course-index-tour';
 import { CoreDom } from '@singletons/dom';
+import { CoreUserTourDirectiveOptions } from '@directives/user-tour';
 
 /**
  * Component to display course contents using a certain format. If the format isn't found, use default one.
@@ -87,6 +88,13 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
     canLoadMore = false;
     showSectionId = 0;
     data: Record<string, unknown> = {}; // Data to pass to the components.
+    courseIndexTour: CoreUserTourDirectiveOptions = {
+        id: 'course-index',
+        component: CoreCourseCourseIndexTourComponent,
+        side: CoreUserToursSide.Top,
+        alignment: CoreUserToursAlignment.End,
+        getFocusedElement: nativeButton => nativeButton.shadowRoot?.children[0] as HTMLElement,
+    };
 
     displayCourseIndex = false;
     displayBlocks = false;
@@ -166,25 +174,6 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
         });
     }
 
-    /**
-     * Show Course Index User Tour.
-     */
-    async showCourseIndexTour(): Promise<void> {
-        const nativeButton = this.courseIndexFab?.nativeElement.shadowRoot?.children[0] as HTMLElement;
-
-        if (!nativeButton) {
-            return;
-        }
-
-        await CoreUserTours.showIfPending({
-            id: 'course-index',
-            component: CoreCourseCourseIndexTourComponent,
-            focus: nativeButton,
-            side: CoreUserToursSide.Top,
-            alignment: CoreUserToursAlignment.End,
-        });
-    }
-
     /**
      * Detect changes on input properties.
      */
diff --git a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html
index bd0e01204..e24ded5c4 100644
--- a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html
+++ b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html
@@ -1,4 +1,4 @@
 <core-user-avatar *ngIf="isMainScreen && siteInfo" [user]="siteInfo" class="core-bar-button-image clickable" [linkProfile]="false"
-    (ariaButtonClick)="openUserMenu($event)" (onAppear)="showTour()" role="button" tabindex="0"
+    (ariaButtonClick)="openUserMenu($event)" [userTour]="userTour" role="button" tabindex="0"
     [attr.aria-label]="'core.user.useraccount' | translate" #avatar>
 </core-user-avatar>
diff --git a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts
index 8c8efb728..54e11f46f 100644
--- a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts
+++ b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts
@@ -14,7 +14,8 @@
 
 import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
 import { CoreSiteInfo } from '@classes/site';
-import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
+import { CoreUserTourDirectiveOptions } from '@directives/user-tour';
+import { CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
 import { IonRouterOutlet } from '@ionic/angular';
 import { CoreScreen } from '@services/screen';
 import { CoreSites } from '@services/sites';
@@ -36,6 +37,12 @@ export class CoreMainMenuUserButtonComponent implements OnInit {
 
     siteInfo?: CoreSiteInfo;
     isMainScreen = false;
+    userTour: CoreUserTourDirectiveOptions = {
+        id: 'user-menu',
+        component: CoreMainMenuUserMenuTourComponent,
+        alignment: CoreUserToursAlignment.Start,
+        side: CoreScreen.isMobile ? CoreUserToursSide.Start : CoreUserToursSide.End,
+    };
 
     @ViewChild('avatar', { read: ElementRef }) avatar?: ElementRef<HTMLElement>;
 
@@ -66,21 +73,4 @@ export class CoreMainMenuUserButtonComponent implements OnInit {
         });
     }
 
-    /**
-     * Show User Tour.
-     */
-    async showTour(): Promise<void> {
-        if (!this.avatar) {
-            return;
-        }
-
-        await CoreUserTours.showIfPending({
-            id: 'user-menu',
-            component: CoreMainMenuUserMenuTourComponent,
-            focus: this.avatar.nativeElement,
-            alignment: CoreUserToursAlignment.Start,
-            side: CoreScreen.isMobile ? CoreUserToursSide.Start : CoreUserToursSide.End,
-        });
-    }
-
 }
diff --git a/src/core/features/usertours/components/user-tour/user-tour.scss b/src/core/features/usertours/components/user-tour/user-tour.scss
index 3cb31e5bc..2e37de273 100644
--- a/src/core/features/usertours/components/user-tour/user-tour.scss
+++ b/src/core/features/usertours/components/user-tour/user-tour.scss
@@ -33,6 +33,7 @@
         color: var(--ion-text-color, #000);
         background: var(--popover-background);
         width: 70vw;
+        max-width: 400px;
         padding: 16px;
         border-radius: 8px;
 
diff --git a/src/core/features/usertours/components/user-tour/user-tour.ts b/src/core/features/usertours/components/user-tour/user-tour.ts
index fae112402..fc32a9f99 100644
--- a/src/core/features/usertours/components/user-tour/user-tour.ts
+++ b/src/core/features/usertours/components/user-tour/user-tour.ts
@@ -55,6 +55,7 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
     popoverWrapperStyles?: string;
     popoverWrapperArrowStyles?: string;
     private element: HTMLElement;
+    private tour?: HTMLElement;
     private wrapperTransform = '';
     private wrapperElement = new CorePromisedValue<HTMLElement>();
     private backButtonListener?: (event: BackButtonEvent) => void;
@@ -77,14 +78,18 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
     }
 
     /**
-     * Present User Tour.
+     * Show User Tour.
      */
-    async present(): Promise<void> {
+    async show(): Promise<void> {
         // Insert tour component and wait until it's ready.
         const wrapper = await this.wrapperElement;
-        const tour = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {});
+        this.tour = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {});
 
-        await CoreDomUtils.waitForImages(tour);
+        if (!this.tour) {
+            return;
+        }
+
+        await CoreDomUtils.waitForImages(this.tour);
 
         // Calculate focus styles or dismiss if the element is gone.
         if (this.focus && !CoreDom.isElementVisible(this.focus)) {
@@ -111,6 +116,19 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
         await this.playEnterAnimation();
     }
 
+    /**
+     * Hide User Tour temporarily.
+     */
+    async hide(): Promise<void> {
+        const wrapper = await this.wrapperElement;
+
+        await this.playLeaveAnimation();
+        await AngularFrameworkDelegate.removeViewFromDom(wrapper, this.tour);
+
+        this.active = false;
+        this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
+    }
+
     /**
      * Dismiss User Tour.
      *
@@ -119,13 +137,15 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
     async dismiss(acknowledge: boolean = true): Promise<void> {
         this.beforeDismiss.emit();
 
-        await this.playLeaveAnimation();
-        await Promise.all<unknown>([
-            AngularFrameworkDelegate.removeViewFromDom(this.container, this.element),
-            acknowledge && CoreUserTours.acknowledge(this.id),
-        ]);
+        if (this.active) {
+            await this.playLeaveAnimation();
+            await AngularFrameworkDelegate.removeViewFromDom(this.container, this.element);
+
+            this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
+        }
+
+        acknowledge && await CoreUserTours.acknowledge(this.id);
 
-        this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
         this.afterDismiss.emit();
     }
 
diff --git a/src/core/features/usertours/services/user-tours.ts b/src/core/features/usertours/services/user-tours.ts
index 749287f1b..fac59c705 100644
--- a/src/core/features/usertours/services/user-tours.ts
+++ b/src/core/features/usertours/services/user-tours.ts
@@ -15,12 +15,14 @@
 import { CoreConstants } from '@/core/constants';
 import { asyncInstance } from '@/core/utils/async-instance';
 import { Injectable } from '@angular/core';
+import { CoreCancellablePromise } from '@classes/cancellable-promise';
 import { CoreDatabaseTable } from '@classes/database/database-table';
 import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
 import { CoreApp } from '@services/app';
 import { CoreUtils } from '@services/utils/utils';
 import { AngularFrameworkDelegate, makeSingleton } from '@singletons';
 import { CoreComponentsRegistry } from '@singletons/components-registry';
+import { CoreDom } from '@singletons/dom';
 import { CoreSubscriptions } from '@singletons/subscriptions';
 import { CoreUserToursUserTourComponent } from '../components/user-tour/user-tour';
 import { APP_SCHEMA, CoreUserToursDBEntry, USER_TOURS_TABLE_NAME } from './database/user-tours';
@@ -32,8 +34,7 @@ import { APP_SCHEMA, CoreUserToursDBEntry, USER_TOURS_TABLE_NAME } from './datab
 export class CoreUserToursService {
 
     protected table = asyncInstance<CoreDatabaseTable<CoreUserToursDBEntry>>();
-    protected tours: CoreUserToursUserTourComponent[] = [];
-    protected tourReadyCallbacks = new WeakMap<CoreUserToursUserTourComponent, () => void>();
+    protected tours: { component: CoreUserToursUserTourComponent; visible: boolean }[] = [];
 
     /**
      * Initialize database.
@@ -84,14 +85,17 @@ export class CoreUserToursService {
      * Show a User Tour if it's pending.
      *
      * @param options User Tour options.
+     * @returns User tour controller, if any.
      */
-    async showIfPending(options: CoreUserToursBasicOptions): Promise<void>;
-    async showIfPending(options: CoreUserToursFocusedOptions): Promise<void>;
-    async showIfPending(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<void> {
+    async showIfPending(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour | null>;
+    async showIfPending(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour | null>;
+    async showIfPending(
+        options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions,
+    ): Promise<CoreUserToursUserTour | null> {
         const isPending = await CoreUserTours.isPending(options.id);
 
         if (!isPending) {
-            return;
+            return null;
         }
 
         return this.show(options);
@@ -101,16 +105,15 @@ export class CoreUserToursService {
      * Show a User Tour.
      *
      * @param options User Tour options.
+     * @returns User tour controller.
      */
-    protected async show(options: CoreUserToursBasicOptions): Promise<void>;
-    protected async show(options: CoreUserToursFocusedOptions): Promise<void>;
-    protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<void> {
+    protected async show(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour>;
+    protected async show(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour>;
+    protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour> {
         const { delay, ...componentOptions } = options;
 
-        // Delay start.
         await CoreUtils.wait(delay ?? 200);
 
-        // Create tour.
         const container = document.querySelector('ion-app') ?? document.body;
         const element = await AngularFrameworkDelegate.attachViewToDom(
             container,
@@ -119,26 +122,7 @@ export class CoreUserToursService {
         );
         const tour = CoreComponentsRegistry.require(element, CoreUserToursUserTourComponent);
 
-        this.tours.push(tour);
-
-        // Handle present/dismiss lifecycle.
-        CoreSubscriptions.once(tour.beforeDismiss, () => {
-            const index = this.tours.indexOf(tour);
-
-            if (index === -1) {
-                return;
-            }
-
-            this.tours.splice(index, 1);
-
-            const nextTour = this.tours[0] as CoreUserToursUserTourComponent | undefined;
-
-            nextTour?.present().then(() => this.tourReadyCallbacks.get(nextTour)?.());
-        });
-
-        this.tours.length > 1
-            ? await new Promise<void>(resolve => this.tourReadyCallbacks.set(tour, resolve))
-            : await tour.present();
+        return this.startTour(tour, options.watch ?? (options as CoreUserToursFocusedOptions).focus);
     }
 
     /**
@@ -147,13 +131,144 @@ export class CoreUserToursService {
      * @param acknowledge Whether to acknowledge that the user has seen this User Tour or not.
      */
     async dismiss(acknowledge: boolean = true): Promise<void> {
-        await this.tours[0]?.dismiss(acknowledge);
+        await this.tours.find(({ visible }) => visible)?.component.dismiss(acknowledge);
+    }
+
+    /**
+     * Activate a tour component and bind its lifecycle to an element if provided.
+     *
+     * @param tour User tour.
+     * @param watchElement Element to watch in order to update tour lifecycle.
+     * @returns User tour controller.
+     */
+    protected startTour(tour: CoreUserToursUserTourComponent, watchElement?: HTMLElement | false): CoreUserToursUserTour {
+        if (!watchElement) {
+            this.activateTour(tour);
+
+            return {
+                cancel: () => tour.dismiss(false),
+            };
+        }
+
+        let unsubscribeVisible: (() => void) | undefined;
+        let visiblePromise: CoreCancellablePromise | undefined = CoreDom.waitToBeInViewport(watchElement);
+
+        // eslint-disable-next-line promise/catch-or-return, promise/always-return
+        visiblePromise.then(() => {
+            visiblePromise = undefined;
+
+            this.activateTour(tour);
+
+            unsubscribeVisible = CoreDom.watchElementInViewport(
+                watchElement,
+                visible => visible ? this.activateTour(tour) : this.deactivateTour(tour),
+            );
+
+            CoreSubscriptions.once(tour.beforeDismiss, () => {
+                unsubscribeVisible?.();
+
+                visiblePromise = undefined;
+                unsubscribeVisible = undefined;
+            });
+        });
+
+        return {
+            cancel: async () => {
+                visiblePromise?.cancel();
+
+                if (!unsubscribeVisible) {
+                    return;
+                }
+
+                unsubscribeVisible();
+
+                await tour.dismiss(false);
+            },
+        };
+    }
+
+    /**
+     * Activate the given user tour.
+     *
+     * @param tour User tour.
+     */
+    protected activateTour(tour: CoreUserToursUserTourComponent): void {
+        // Handle show/dismiss lifecycle.
+        CoreSubscriptions.once(tour.beforeDismiss, () => {
+            const index = this.tours.findIndex(({ component }) => component === tour);
+
+            if (index === -1) {
+                return;
+            }
+
+            this.tours.splice(index, 1);
+
+            const foregroundTour = this.tours.find(({ visible }) => visible);
+
+            foregroundTour?.component.show();
+        });
+
+        // Add to existing tours and show it if it's on top.
+        const index = this.tours.findIndex(({ component }) => component === tour);
+        const foregroundTour = this.tours.find(({ visible }) => visible);
+
+        if (index !== -1) {
+            this.tours[index].visible = true;
+        } else {
+            this.tours.push({
+                visible: true,
+                component: tour,
+            });
+        }
+
+        if (this.tours.find(({ visible }) => visible)?.component !== tour) {
+            return;
+        }
+
+        foregroundTour?.component.hide();
+
+        tour.show();
+    }
+
+    /**
+     * Hide User Tour if visible.
+     *
+     * @param tour User tour.
+     */
+    protected deactivateTour(tour: CoreUserToursUserTourComponent): void {
+        const index = this.tours.findIndex(({ component }) => component === tour);
+        const foregroundTourIndex = this.tours.findIndex(({ visible }) => visible);
+
+        if (index === -1) {
+            return;
+        }
+
+        this.tours[index].visible = false;
+
+        if (index === foregroundTourIndex) {
+            tour.hide();
+
+            this.tours.find(({ visible }) => visible)?.component.show();
+        }
     }
 
 }
 
 export const CoreUserTours = makeSingleton(CoreUserToursService);
 
+/**
+ * User Tour controller.
+ */
+export interface CoreUserToursUserTour {
+
+    /**
+     * Cancelling a User Tours removed it from the queue if it was pending or dimisses it without
+     * acknowledging if it existed.
+     */
+    cancel(): Promise<void>;
+
+}
+
 /**
  * User Tour side.
  */
@@ -202,6 +317,13 @@ export interface CoreUserToursBasicOptions {
      */
     delay?: number;
 
+    /**
+     * Whether to watch an element to bind the User Tour lifecycle. Whenever this element appears or
+     * leaves the screen, the user tour will do it as well. Focused user tours do it by default with
+     * the focused element, but it can be disabled by explicitly using `false` here.
+     */
+    watch?: HTMLElement | false;
+
 }
 
 /**
diff --git a/src/core/singletons/dom.ts b/src/core/singletons/dom.ts
index 35306cc12..914cc6ff2 100644
--- a/src/core/singletons/dom.ts
+++ b/src/core/singletons/dom.ts
@@ -354,6 +354,62 @@ export class CoreDom {
         );
     }
 
+    /**
+     * Watch whenever an elements visibility changes within the viewport.
+     *
+     * @param element Element to watch.
+     * @param intersectionRatio Intersection ratio (From 0 to 1).
+     * @param callback Callback when visibility changes.
+     * @return Function to stop watching.
+     */
+    static watchElementInViewport(
+        element: HTMLElement,
+        intersectionRatio: number,
+        callback: (visible: boolean) => void,
+    ): () => void;
+
+    /**
+     * Watch whenever an elements visibility changes within the viewport.
+     *
+     * @param element Element to watch.
+     * @param callback Callback when visibility changes.
+     * @return Function to stop watching.
+     */
+    static watchElementInViewport(element: HTMLElement, callback: (visible: boolean) => void): () => void;
+
+    static watchElementInViewport(
+        element: HTMLElement,
+        intersectionRatioOrCallback: number | ((visible: boolean) => void),
+        callback?: (visible: boolean) => void,
+    ): () => void {
+        const visibleCallback = callback ?? intersectionRatioOrCallback as (visible: boolean) => void;
+        const intersectionRatio = typeof intersectionRatioOrCallback === 'number' ? intersectionRatioOrCallback : 1;
+
+        let visible = CoreDom.isElementInViewport(element, intersectionRatio);
+        const setVisible = (newValue: boolean) => {
+            if (visible === newValue) {
+                return;
+            }
+
+            visible = newValue;
+            visibleCallback(visible);
+        };
+
+        if (!('IntersectionObserver' in window)) {
+            const interval = setInterval(() => setVisible(CoreDom.isElementInViewport(element, intersectionRatio)), 50);
+
+            return () => clearInterval(interval);
+        }
+
+        const observer = new IntersectionObserver(([{ isIntersecting, intersectionRatio }]) => {
+            setVisible(isIntersecting && intersectionRatio >= intersectionRatio);
+        });
+
+        observer.observe(element);
+
+        return () => observer.disconnect();
+    }
+
     /**
      * Wait an element to be in dom and visible.
      *
@@ -362,48 +418,29 @@ export class CoreDom {
      * @return Cancellable promise.
      */
     static waitToBeInViewport(element: HTMLElement, intersectionRatio = 1): CoreCancellablePromise<void> {
+        let unsubscribe: (() => void) | undefined;
         const visiblePromise = CoreDom.waitToBeVisible(element);
 
-        let intersectionObserver: IntersectionObserver;
-        let interval: number | undefined;
-
         return new CoreCancellablePromise<void>(
             async (resolve) => {
                 await visiblePromise;
 
                 if (CoreDom.isElementInViewport(element, intersectionRatio)) {
-
                     return resolve();
                 }
 
-                if ('IntersectionObserver' in window) {
-                    intersectionObserver = new IntersectionObserver((observerEntries) => {
-                        const isIntersecting = observerEntries
-                            .some((entry) => entry.isIntersecting && entry.intersectionRatio >= intersectionRatio);
-                        if (!isIntersecting) {
-                            return;
-                        }
+                unsubscribe = this.watchElementInViewport(element, intersectionRatio, inViewport => {
+                    if (!inViewport) {
+                        return;
+                    }
 
-                        resolve();
-                        intersectionObserver?.disconnect();
-                    });
-
-                    intersectionObserver.observe(element);
-                } else {
-                    interval = window.setInterval(() => {
-                        if (!CoreDom.isElementInViewport(element, intersectionRatio)) {
-                            return;
-                        }
-
-                        resolve();
-                        window.clearInterval(interval);
-                    }, 50);
-                }
+                    resolve();
+                    unsubscribe?.();
+                });
             },
             () => {
                 visiblePromise.cancel();
-                intersectionObserver?.disconnect();
-                window.clearInterval(interval);
+                unsubscribe?.();
             },
         );
     }