Mobile Onboarding Screens — Free App Intro UI Snippet

Mobile Onboarding Screens · Layouts · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Sliding track
One translateX moves between full-width slides.
Swipe and buttons
Both navigate through a single go() function.
Worm progress dots
The active dot stretches into a pill.
Morphing blobs
Animated border-radius art with no images.
Threshold swipe
Pointer delta past 45px changes slides.
Context-aware controls
Final slide relabels Next and hides Skip.
No desync
Dots, label, and slide always agree.
No dependency
Pure HTML/CSS/JS, no carousel library.

About this UI Snippet

Mobile Onboarding Screens — Swipeable App Intro Carousel

Screenshot of the Mobile Onboarding Screens snippet rendered live

An onboarding flow is the set of intro screens a new user swipes through on first launch — each pitching one feature with an illustration, a headline, and a line of copy, with progress dots and a skip option. This snippet builds a complete, swipeable one inside a CSS phone frame in HTML, CSS, and vanilla JavaScript with no dependency, ready to adapt to any app.

A sliding track of full-width slides

All slides sit in a flex .ob-track where each slide is min-width: 100%, so they line up horizontally. Navigation is a single transform: translateX(-index * 100%) on the track, transitioned with a cubic-bezier ease — moving between screens is just changing one index and letting CSS animate the slide. There's no per-slide show/hide, which keeps state trivial and the motion smooth.

Swipe and buttons share one navigator

A go(i) function clamps the index, sets the transform, updates the dots, and relabels the button — every navigation path funnels through it. The Next button advances, Skip jumps to the last slide, and a pointer swipe (comparing start and end clientX against a 45px threshold) moves a slide in either direction. Because they all call go(), the dots, button label, and slide position can never desync.

Animated progress dots

The dots aren't just filled circles — the active one stretches into a pill (width animates from 7px to 22px) while the others stay small, the modern "worm" indicator. It's driven by toggling an .on class, with a CSS transition doing the morph, so it reads as a continuous progress bar split into segments.

Morphing blob illustrations

Each slide's art is an emoji glyph over a gradient blob whose organic shape comes from a multi-value border-radius (e.g. 42% 58% 63% 37% / 45% 38% 62% 55%) animated between two shapes on an infinite obMorph keyframe. It gives lively, on-brand artwork with zero images — swap the gradient and glyph per slide to match your features.

Context-aware controls

On the last slide the Next button becomes "Get started" and Skip hides, because skipping the final screen makes no sense — small touches that make the flow feel finished. Wire the final button to your home route or auth screen.

Reusing it

Keep the slide structure and replace the copy, glyphs, and gradients. Drop it into a phone mockup for presentations, or lift it out as a responsive web onboarding carousel.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA three-screen onboarding flow renders in a phone frame.
  2. 2
    Press NextThe track slides to the next screen and the dots advance.
  3. 3
    SwipeDrag left or right to move between slides past a threshold.
  4. 4
    Tap SkipJump straight to the final get-started screen.
  5. 5
    Reach the endNext becomes Get started and Skip hides.
  6. 6
    CustomizeSwap the copy, glyphs, and blob gradients per feature.

Real-world uses

Common Use Cases

App first-run intros
Introduce features inside a phone mockup.
Feature tours
A lighter alternative to an onboarding tour.
Lead into sign-in
Hand off to a mobile login screen.
Web onboarding
Lift it out as a carousel-style intro.
Product walkthroughs
Pair with a progress wizard for steps.
Learning sliders
A reference for translateX carousels and swipe.

Got questions?

Frequently Asked Questions

All slides live in a flex track, each at min-width 100%, so they sit side by side. Moving is a single transform: translateX(-index * 100%) on the track with a CSS transition, so changing the index animates the slide. There's no per-slide visibility toggling, which keeps the logic and the motion simple.

Every navigation — Next, Skip, and swipe — calls one go(i) function that clamps the index, sets the transform, updates the progress dots, and relabels the button. Because there's a single source of truth, the dots, the button text, and the visible slide can never drift out of agreement.

A pointerdown records the start X, and a window pointerup compares the end X. If the horizontal delta is less than -45 pixels it advances, more than +45 it goes back, otherwise it stays — a threshold that ignores tiny accidental drags. Listening for pointerup on the window catches releases even outside the track.

No. Each is an emoji glyph layered over a gradient blob whose organic shape comes from a multi-value border-radius animated between two shapes on an infinite keyframe. Swapping the gradient and glyph per slide gives distinct, lively artwork for each feature with no image assets to load.

Keep the active index in state and bind the track's transform to it. Implement Next, Skip, and swipe handlers that update the index. Render slides from a data array of glyph, title, and copy. The progress dots derive from the index. In Tailwind, set slides to w-full shrink-0 in a flex track and animate the active dot's width.

Mobile Onboarding Screens — Export as HTML, React, Vue, Angular & Tailwind

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Mobile Onboarding Screens</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#1e293b;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}
    
    .ob-phone{width:288px;height:600px;background:#0b1220;border-radius:46px;padding:12px;box-shadow:0 30px 60px -20px rgba(0,0,0,.6),inset 0 0 0 2px #1e293b}
    .ob-screen{width:100%;height:100%;border-radius:34px;overflow:hidden;background:#fff;color:#0f172a;display:flex;flex-direction:column}
    .ob-top{display:flex;justify-content:space-between;align-items:center;padding:13px 22px 0;font-size:13px;font-weight:700}
    .ob-skip{background:none;border:none;font-size:13px;font-weight:700;color:#94a3b8;cursor:pointer;font-family:inherit}
    
    .ob-track{flex:1;display:flex;overflow:hidden;transition:transform .4s cubic-bezier(.65,0,.35,1)}
    .ob-slide{min-width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:24px 30px}
    .ob-art{position:relative;width:150px;height:150px;display:flex;align-items:center;justify-content:center;margin-bottom:34px}
    .ob-blob{position:absolute;inset:0;border-radius:42% 58% 63% 37%/45% 38% 62% 55%;animation:obMorph 8s ease-in-out infinite}
    .a1 .ob-blob{background:linear-gradient(135deg,#a5b4fc,#6366f1)}
    .a2 .ob-blob{background:linear-gradient(135deg,#fcd34d,#f59e0b)}
    .a3 .ob-blob{background:linear-gradient(135deg,#6ee7b7,#10b981)}
    .ob-glyph{position:relative;font-size:52px;z-index:2}
    @keyframes obMorph{50%{border-radius:58% 42% 38% 62%/55% 62% 38% 45%}}
    .ob-slide h2{font-size:23px;font-weight:800;margin-bottom:10px}
    .ob-slide p{font-size:14px;color:#64748b;line-height:1.55;max-width:230px}
    
    .ob-foot{display:flex;align-items:center;justify-content:space-between;padding:18px 26px 26px}
    .ob-dots{display:flex;gap:7px}
    .ob-dots span{width:7px;height:7px;border-radius:99px;background:#e2e8f0;transition:width .3s,background .3s}
    .ob-dots span.on{width:22px;background:#6366f1}
    .ob-next{background:#6366f1;color:#fff;border:none;border-radius:12px;padding:12px 24px;font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;transition:background .15s}
    .ob-next:hover{background:#4f46e5}
  </style>
</head>
<body>
  <div class="ob-phone">
    <div class="ob-screen">
      <div class="ob-top"><span>9:41</span><button type="button" class="ob-skip" id="obSkip">Skip</button></div>
      <div class="ob-track" id="obTrack">
        <div class="ob-slide">
          <div class="ob-art a1"><span class="ob-blob"></span><span class="ob-glyph">📊</span></div>
          <h2>Track everything</h2>
          <p>See all your accounts and spending in one clean dashboard.</p>
        </div>
        <div class="ob-slide">
          <div class="ob-art a2"><span class="ob-blob"></span><span class="ob-glyph">⚡</span></div>
          <h2>Move money fast</h2>
          <p>Send, request, and split payments in just a couple of taps.</p>
        </div>
        <div class="ob-slide">
          <div class="ob-art a3"><span class="ob-blob"></span><span class="ob-glyph">🔒</span></div>
          <h2>Bank-grade security</h2>
          <p>Your data is encrypted end-to-end and protected with Face ID.</p>
        </div>
      </div>
      <div class="ob-foot">
        <div class="ob-dots" id="obDots"><span class="on"></span><span></span><span></span></div>
        <button type="button" class="ob-next" id="obNext">Next</button>
      </div>
    </div>
  </div>
  <script>
    var track = document.getElementById('obTrack');
    var dots = document.getElementById('obDots').children;
    var nextBtn = document.getElementById('obNext');
    var skip = document.getElementById('obSkip');
    var total = track.children.length;
    var index = 0;
    
    function go(i) {
      index = Math.max(0, Math.min(total - 1, i));
      track.style.transform = 'translateX(' + (-index * 100) + '%)';
      for (var d = 0; d < dots.length; d++) dots[d].classList.toggle('on', d === index);
      nextBtn.textContent = index === total - 1 ? 'Get started' : 'Next';
      skip.style.visibility = index === total - 1 ? 'hidden' : 'visible';
    }
    
    nextBtn.addEventListener('click', function () {
      if (index === total - 1) { nextBtn.textContent = 'Welcome! 🎉'; return; }
      go(index + 1);
    });
    skip.addEventListener('click', function () { go(total - 1); });
    
    // Allow swiping between slides.
    var startX = null;
    track.addEventListener('pointerdown', function (e) { startX = e.clientX; });
    window.addEventListener('pointerup', function (e) {
      if (startX === null) return;
      var dx = e.clientX - startX;
      if (dx < -45) go(index + 1); else if (dx > 45) go(index - 1);
      startX = null;
    });
    
    go(0);
  </script>
</body>
</html>
Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Mobile Onboarding Screens</title>
  <!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    @keyframes obMorph{50%{border-radius:58% 42% 38% 62%/55% 62% 38% 45%}}

    * {
      box-sizing:border-box;margin:0;padding:0
    }

    body {
      font-family:system-ui,-apple-system,sans-serif;background:#1e293b;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px
    }

    .a1 .ob-blob {
      background:linear-gradient(135deg,#a5b4fc,#6366f1)
    }

    .a2 .ob-blob {
      background:linear-gradient(135deg,#fcd34d,#f59e0b)
    }

    .a3 .ob-blob {
      background:linear-gradient(135deg,#6ee7b7,#10b981)
    }

    .ob-slide h2 {
      font-size:23px;font-weight:800;margin-bottom:10px
    }

    .ob-slide p {
      font-size:14px;color:#64748b;line-height:1.55;max-width:230px
    }

    .ob-dots span {
      width:7px;height:7px;border-radius:99px;background:#e2e8f0;transition:width .3s,background .3s
    }

    .ob-dots span.on {
      width:22px;background:#6366f1
    }
  </style>
</head>
<body>
  <div class="w-72 h-[600px] bg-[#0b1220] rounded-[46px] p-3 shadow-[0_30px_60px_-20px_rgba(0,0,0,.6),inset_0_0_0_2px_#1e293b]">
    <div class="w-full h-full rounded-[34px] overflow-hidden bg-[#fff] text-[#0f172a] flex flex-col">
      <div class="flex justify-between items-center pt-[13px] px-[22px] pb-0 text-[13px] font-bold"><span>9:41</span><button type="button" class="bg-transparent border-0 text-[13px] font-bold text-[#94a3b8] cursor-pointer font-[inherit]" id="obSkip">Skip</button></div>
      <div class="flex-1 flex overflow-hidden [transition:transform_.4s_cubic-bezier(.65,0,.35,1)]" id="obTrack">
        <div class="ob-slide min-w-full flex flex-col items-center justify-center text-center py-6 px-[30px]">
          <div class="relative w-[150px] h-[150px] flex items-center justify-center mb-[34px] a1"><span class="ob-blob absolute inset-0 rounded-[42% 58% 63% 37%/45% 38% 62% 55%] [animation:obMorph_8s_ease-in-out_infinite]"></span><span class="relative text-[52px] z-[2]">📊</span></div>
          <h2>Track everything</h2>
          <p>See all your accounts and spending in one clean dashboard.</p>
        </div>
        <div class="ob-slide min-w-full flex flex-col items-center justify-center text-center py-6 px-[30px]">
          <div class="relative w-[150px] h-[150px] flex items-center justify-center mb-[34px] a2"><span class="ob-blob absolute inset-0 rounded-[42% 58% 63% 37%/45% 38% 62% 55%] [animation:obMorph_8s_ease-in-out_infinite]"></span><span class="relative text-[52px] z-[2]">⚡</span></div>
          <h2>Move money fast</h2>
          <p>Send, request, and split payments in just a couple of taps.</p>
        </div>
        <div class="ob-slide min-w-full flex flex-col items-center justify-center text-center py-6 px-[30px]">
          <div class="relative w-[150px] h-[150px] flex items-center justify-center mb-[34px] a3"><span class="ob-blob absolute inset-0 rounded-[42% 58% 63% 37%/45% 38% 62% 55%] [animation:obMorph_8s_ease-in-out_infinite]"></span><span class="relative text-[52px] z-[2]">🔒</span></div>
          <h2>Bank-grade security</h2>
          <p>Your data is encrypted end-to-end and protected with Face ID.</p>
        </div>
      </div>
      <div class="flex items-center justify-between pt-[18px] px-[26px] pb-[26px]">
        <div class="ob-dots flex gap-[7px]" id="obDots"><span class="on"></span><span></span><span></span></div>
        <button type="button" class="bg-[#6366f1] text-[#fff] border-0 rounded-xl py-3 px-6 text-sm font-bold cursor-pointer font-[inherit] [transition:background_.15s] hover:bg-[#4f46e5]" id="obNext">Next</button>
      </div>
    </div>
  </div>
  <script>
    var track = document.getElementById('obTrack');
    var dots = document.getElementById('obDots').children;
    var nextBtn = document.getElementById('obNext');
    var skip = document.getElementById('obSkip');
    var total = track.children.length;
    var index = 0;
    
    function go(i) {
      index = Math.max(0, Math.min(total - 1, i));
      track.style.transform = 'translateX(' + (-index * 100) + '%)';
      for (var d = 0; d < dots.length; d++) dots[d].classList.toggle('on', d === index);
      nextBtn.textContent = index === total - 1 ? 'Get started' : 'Next';
      skip.style.visibility = index === total - 1 ? 'hidden' : 'visible';
    }
    
    nextBtn.addEventListener('click', function () {
      if (index === total - 1) { nextBtn.textContent = 'Welcome! 🎉'; return; }
      go(index + 1);
    });
    skip.addEventListener('click', function () { go(total - 1); });
    
    // Allow swiping between slides.
    var startX = null;
    track.addEventListener('pointerdown', function (e) { startX = e.clientX; });
    window.addEventListener('pointerup', function (e) {
      if (startX === null) return;
      var dx = e.clientX - startX;
      if (dx < -45) go(index + 1); else if (dx > 45) go(index - 1);
      startX = null;
    });
    
    go(0);
  </script>
</body>
</html>
React
import React, { useEffect } from 'react';

// CSS — optionally move to MobileOnboardingScreens.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#1e293b;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}

.ob-phone{width:288px;height:600px;background:#0b1220;border-radius:46px;padding:12px;box-shadow:0 30px 60px -20px rgba(0,0,0,.6),inset 0 0 0 2px #1e293b}
.ob-screen{width:100%;height:100%;border-radius:34px;overflow:hidden;background:#fff;color:#0f172a;display:flex;flex-direction:column}
.ob-top{display:flex;justify-content:space-between;align-items:center;padding:13px 22px 0;font-size:13px;font-weight:700}
.ob-skip{background:none;border:none;font-size:13px;font-weight:700;color:#94a3b8;cursor:pointer;font-family:inherit}

.ob-track{flex:1;display:flex;overflow:hidden;transition:transform .4s cubic-bezier(.65,0,.35,1)}
.ob-slide{min-width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:24px 30px}
.ob-art{position:relative;width:150px;height:150px;display:flex;align-items:center;justify-content:center;margin-bottom:34px}
.ob-blob{position:absolute;inset:0;border-radius:42% 58% 63% 37%/45% 38% 62% 55%;animation:obMorph 8s ease-in-out infinite}
.a1 .ob-blob{background:linear-gradient(135deg,#a5b4fc,#6366f1)}
.a2 .ob-blob{background:linear-gradient(135deg,#fcd34d,#f59e0b)}
.a3 .ob-blob{background:linear-gradient(135deg,#6ee7b7,#10b981)}
.ob-glyph{position:relative;font-size:52px;z-index:2}
@keyframes obMorph{50%{border-radius:58% 42% 38% 62%/55% 62% 38% 45%}}
.ob-slide h2{font-size:23px;font-weight:800;margin-bottom:10px}
.ob-slide p{font-size:14px;color:#64748b;line-height:1.55;max-width:230px}

.ob-foot{display:flex;align-items:center;justify-content:space-between;padding:18px 26px 26px}
.ob-dots{display:flex;gap:7px}
.ob-dots span{width:7px;height:7px;border-radius:99px;background:#e2e8f0;transition:width .3s,background .3s}
.ob-dots span.on{width:22px;background:#6366f1}
.ob-next{background:#6366f1;color:#fff;border:none;border-radius:12px;padding:12px 24px;font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;transition:background .15s}
.ob-next:hover{background:#4f46e5}
`;

export default function MobileOnboardingScreens() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var track = document.getElementById('obTrack');
    var dots = document.getElementById('obDots').children;
    var nextBtn = document.getElementById('obNext');
    var skip = document.getElementById('obSkip');
    var total = track.children.length;
    var index = 0;
    
    function go(i) {
      index = Math.max(0, Math.min(total - 1, i));
      track.style.transform = 'translateX(' + (-index * 100) + '%)';
      for (var d = 0; d < dots.length; d++) dots[d].classList.toggle('on', d === index);
      nextBtn.textContent = index === total - 1 ? 'Get started' : 'Next';
      skip.style.visibility = index === total - 1 ? 'hidden' : 'visible';
    }
    
    nextBtn.addEventListener('click', function () {
      if (index === total - 1) { nextBtn.textContent = 'Welcome! 🎉'; return; }
      go(index + 1);
    });
    skip.addEventListener('click', function () { go(total - 1); });
    
    // Allow swiping between slides.
    var startX = null;
    track.addEventListener('pointerdown', function (e) { startX = e.clientX; });
    window.addEventListener('pointerup', function (e) {
      if (startX === null) return;
      var dx = e.clientX - startX;
      if (dx < -45) go(index + 1); else if (dx > 45) go(index - 1);
      startX = null;
    });
    
    go(0);
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{css}</style>
      <div className="ob-phone">
        <div className="ob-screen">
          <div className="ob-top"><span>9:41</span><button type="button" className="ob-skip" id="obSkip">Skip</button></div>
          <div className="ob-track" id="obTrack">
            <div className="ob-slide">
              <div className="ob-art a1"><span className="ob-blob"></span><span className="ob-glyph">📊</span></div>
              <h2>Track everything</h2>
              <p>See all your accounts and spending in one clean dashboard.</p>
            </div>
            <div className="ob-slide">
              <div className="ob-art a2"><span className="ob-blob"></span><span className="ob-glyph">⚡</span></div>
              <h2>Move money fast</h2>
              <p>Send, request, and split payments in just a couple of taps.</p>
            </div>
            <div className="ob-slide">
              <div className="ob-art a3"><span className="ob-blob"></span><span className="ob-glyph">🔒</span></div>
              <h2>Bank-grade security</h2>
              <p>Your data is encrypted end-to-end and protected with Face ID.</p>
            </div>
          </div>
          <div className="ob-foot">
            <div className="ob-dots" id="obDots"><span className="on"></span><span></span><span></span></div>
            <button type="button" className="ob-next" id="obNext">Next</button>
          </div>
        </div>
      </div>
    </>
  );
}
React + Tailwind
import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+

export default function MobileOnboardingScreens() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var track = document.getElementById('obTrack');
    var dots = document.getElementById('obDots').children;
    var nextBtn = document.getElementById('obNext');
    var skip = document.getElementById('obSkip');
    var total = track.children.length;
    var index = 0;
    
    function go(i) {
      index = Math.max(0, Math.min(total - 1, i));
      track.style.transform = 'translateX(' + (-index * 100) + '%)';
      for (var d = 0; d < dots.length; d++) dots[d].classList.toggle('on', d === index);
      nextBtn.textContent = index === total - 1 ? 'Get started' : 'Next';
      skip.style.visibility = index === total - 1 ? 'hidden' : 'visible';
    }
    
    nextBtn.addEventListener('click', function () {
      if (index === total - 1) { nextBtn.textContent = 'Welcome! 🎉'; return; }
      go(index + 1);
    });
    skip.addEventListener('click', function () { go(total - 1); });
    
    // Allow swiping between slides.
    var startX = null;
    track.addEventListener('pointerdown', function (e) { startX = e.clientX; });
    window.addEventListener('pointerup', function (e) {
      if (startX === null) return;
      var dx = e.clientX - startX;
      if (dx < -45) go(index + 1); else if (dx > 45) go(index - 1);
      startX = null;
    });
    
    go(0);
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{`
@keyframes obMorph{50%{border-radius:58% 42% 38% 62%/55% 62% 38% 45%}}

* {
  box-sizing:border-box;margin:0;padding:0
}

body {
  font-family:system-ui,-apple-system,sans-serif;background:#1e293b;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px
}

.a1 .ob-blob {
  background:linear-gradient(135deg,#a5b4fc,#6366f1)
}

.a2 .ob-blob {
  background:linear-gradient(135deg,#fcd34d,#f59e0b)
}

.a3 .ob-blob {
  background:linear-gradient(135deg,#6ee7b7,#10b981)
}

.ob-slide h2 {
  font-size:23px;font-weight:800;margin-bottom:10px
}

.ob-slide p {
  font-size:14px;color:#64748b;line-height:1.55;max-width:230px
}

.ob-dots span {
  width:7px;height:7px;border-radius:99px;background:#e2e8f0;transition:width .3s,background .3s
}

.ob-dots span.on {
  width:22px;background:#6366f1
}
      `}</style>
      <div className="w-72 h-[600px] bg-[#0b1220] rounded-[46px] p-3 shadow-[0_30px_60px_-20px_rgba(0,0,0,.6),inset_0_0_0_2px_#1e293b]">
        <div className="w-full h-full rounded-[34px] overflow-hidden bg-[#fff] text-[#0f172a] flex flex-col">
          <div className="flex justify-between items-center pt-[13px] px-[22px] pb-0 text-[13px] font-bold"><span>9:41</span><button type="button" className="bg-transparent border-0 text-[13px] font-bold text-[#94a3b8] cursor-pointer font-[inherit]" id="obSkip">Skip</button></div>
          <div className="flex-1 flex overflow-hidden [transition:transform_.4s_cubic-bezier(.65,0,.35,1)]" id="obTrack">
            <div className="ob-slide min-w-full flex flex-col items-center justify-center text-center py-6 px-[30px]">
              <div className="relative w-[150px] h-[150px] flex items-center justify-center mb-[34px] a1"><span className="ob-blob absolute inset-0 rounded-[42% 58% 63% 37%/45% 38% 62% 55%] [animation:obMorph_8s_ease-in-out_infinite]"></span><span className="relative text-[52px] z-[2]">📊</span></div>
              <h2>Track everything</h2>
              <p>See all your accounts and spending in one clean dashboard.</p>
            </div>
            <div className="ob-slide min-w-full flex flex-col items-center justify-center text-center py-6 px-[30px]">
              <div className="relative w-[150px] h-[150px] flex items-center justify-center mb-[34px] a2"><span className="ob-blob absolute inset-0 rounded-[42% 58% 63% 37%/45% 38% 62% 55%] [animation:obMorph_8s_ease-in-out_infinite]"></span><span className="relative text-[52px] z-[2]">⚡</span></div>
              <h2>Move money fast</h2>
              <p>Send, request, and split payments in just a couple of taps.</p>
            </div>
            <div className="ob-slide min-w-full flex flex-col items-center justify-center text-center py-6 px-[30px]">
              <div className="relative w-[150px] h-[150px] flex items-center justify-center mb-[34px] a3"><span className="ob-blob absolute inset-0 rounded-[42% 58% 63% 37%/45% 38% 62% 55%] [animation:obMorph_8s_ease-in-out_infinite]"></span><span className="relative text-[52px] z-[2]">🔒</span></div>
              <h2>Bank-grade security</h2>
              <p>Your data is encrypted end-to-end and protected with Face ID.</p>
            </div>
          </div>
          <div className="flex items-center justify-between pt-[18px] px-[26px] pb-[26px]">
            <div className="ob-dots flex gap-[7px]" id="obDots"><span className="on"></span><span></span><span></span></div>
            <button type="button" className="bg-[#6366f1] text-[#fff] border-0 rounded-xl py-3 px-6 text-sm font-bold cursor-pointer font-[inherit] [transition:background_.15s] hover:bg-[#4f46e5]" id="obNext">Next</button>
          </div>
        </div>
      </div>
    </>
  );
}
Vue
<template>
  <div class="ob-phone">
    <div class="ob-screen">
      <div class="ob-top"><span>9:41</span><button type="button" class="ob-skip" id="obSkip">Skip</button></div>
      <div class="ob-track" id="obTrack">
        <div class="ob-slide">
          <div class="ob-art a1"><span class="ob-blob"></span><span class="ob-glyph">📊</span></div>
          <h2>Track everything</h2>
          <p>See all your accounts and spending in one clean dashboard.</p>
        </div>
        <div class="ob-slide">
          <div class="ob-art a2"><span class="ob-blob"></span><span class="ob-glyph">⚡</span></div>
          <h2>Move money fast</h2>
          <p>Send, request, and split payments in just a couple of taps.</p>
        </div>
        <div class="ob-slide">
          <div class="ob-art a3"><span class="ob-blob"></span><span class="ob-glyph">🔒</span></div>
          <h2>Bank-grade security</h2>
          <p>Your data is encrypted end-to-end and protected with Face ID.</p>
        </div>
      </div>
      <div class="ob-foot">
        <div class="ob-dots" id="obDots"><span class="on"></span><span></span><span></span></div>
        <button type="button" class="ob-next" id="obNext">Next</button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { onMounted } from 'vue';

let track;
let dots;
let nextBtn;
let skip;
let total;
var index = 0;
function go(i) {
  index = Math.max(0, Math.min(total - 1, i));
  track.style.transform = 'translateX(' + (-index * 100) + '%)';
  for (var d = 0; d < dots.length; d++) dots[d].classList.toggle('on', d === index);
  nextBtn.textContent = index === total - 1 ? 'Get started' : 'Next';
  skip.style.visibility = index === total - 1 ? 'hidden' : 'visible';
}
// Allow swiping between slides.
var startX = null;

onMounted(() => {
  track = document.getElementById('obTrack');
  dots = document.getElementById('obDots').children;
  nextBtn = document.getElementById('obNext');
  skip = document.getElementById('obSkip');
  total = track.children.length;
  nextBtn.addEventListener('click', function () {
    if (index === total - 1) { nextBtn.textContent = 'Welcome! 🎉'; return; }
    go(index + 1);
  });
  skip.addEventListener('click', function () { go(total - 1); });
  track.addEventListener('pointerdown', function (e) { startX = e.clientX; });
  window.addEventListener('pointerup', function (e) {
    if (startX === null) return;
    var dx = e.clientX - startX;
    if (dx < -45) go(index + 1); else if (dx > 45) go(index - 1);
    startX = null;
  });
  go(0);
});
</script>

<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#1e293b;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}

.ob-phone{width:288px;height:600px;background:#0b1220;border-radius:46px;padding:12px;box-shadow:0 30px 60px -20px rgba(0,0,0,.6),inset 0 0 0 2px #1e293b}
.ob-screen{width:100%;height:100%;border-radius:34px;overflow:hidden;background:#fff;color:#0f172a;display:flex;flex-direction:column}
.ob-top{display:flex;justify-content:space-between;align-items:center;padding:13px 22px 0;font-size:13px;font-weight:700}
.ob-skip{background:none;border:none;font-size:13px;font-weight:700;color:#94a3b8;cursor:pointer;font-family:inherit}

.ob-track{flex:1;display:flex;overflow:hidden;transition:transform .4s cubic-bezier(.65,0,.35,1)}
.ob-slide{min-width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:24px 30px}
.ob-art{position:relative;width:150px;height:150px;display:flex;align-items:center;justify-content:center;margin-bottom:34px}
.ob-blob{position:absolute;inset:0;border-radius:42% 58% 63% 37%/45% 38% 62% 55%;animation:obMorph 8s ease-in-out infinite}
.a1 .ob-blob{background:linear-gradient(135deg,#a5b4fc,#6366f1)}
.a2 .ob-blob{background:linear-gradient(135deg,#fcd34d,#f59e0b)}
.a3 .ob-blob{background:linear-gradient(135deg,#6ee7b7,#10b981)}
.ob-glyph{position:relative;font-size:52px;z-index:2}
@keyframes obMorph{50%{border-radius:58% 42% 38% 62%/55% 62% 38% 45%}}
.ob-slide h2{font-size:23px;font-weight:800;margin-bottom:10px}
.ob-slide p{font-size:14px;color:#64748b;line-height:1.55;max-width:230px}

.ob-foot{display:flex;align-items:center;justify-content:space-between;padding:18px 26px 26px}
.ob-dots{display:flex;gap:7px}
.ob-dots span{width:7px;height:7px;border-radius:99px;background:#e2e8f0;transition:width .3s,background .3s}
.ob-dots span.on{width:22px;background:#6366f1}
.ob-next{background:#6366f1;color:#fff;border:none;border-radius:12px;padding:12px 24px;font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;transition:background .15s}
.ob-next:hover{background:#4f46e5}
</style>
Angular
// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-mobile-onboarding-screens',
  standalone: true,
  imports: [CommonModule],
  encapsulation: ViewEncapsulation.None,
  template: `
    <div class="ob-phone">
      <div class="ob-screen">
        <div class="ob-top"><span>9:41</span><button type="button" class="ob-skip" id="obSkip">Skip</button></div>
        <div class="ob-track" id="obTrack">
          <div class="ob-slide">
            <div class="ob-art a1"><span class="ob-blob"></span><span class="ob-glyph">📊</span></div>
            <h2>Track everything</h2>
            <p>See all your accounts and spending in one clean dashboard.</p>
          </div>
          <div class="ob-slide">
            <div class="ob-art a2"><span class="ob-blob"></span><span class="ob-glyph">⚡</span></div>
            <h2>Move money fast</h2>
            <p>Send, request, and split payments in just a couple of taps.</p>
          </div>
          <div class="ob-slide">
            <div class="ob-art a3"><span class="ob-blob"></span><span class="ob-glyph">🔒</span></div>
            <h2>Bank-grade security</h2>
            <p>Your data is encrypted end-to-end and protected with Face ID.</p>
          </div>
        </div>
        <div class="ob-foot">
          <div class="ob-dots" id="obDots"><span class="on"></span><span></span><span></span></div>
          <button type="button" class="ob-next" id="obNext">Next</button>
        </div>
      </div>
    </div>
  `,
  styles: [`
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#1e293b;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}
    
    .ob-phone{width:288px;height:600px;background:#0b1220;border-radius:46px;padding:12px;box-shadow:0 30px 60px -20px rgba(0,0,0,.6),inset 0 0 0 2px #1e293b}
    .ob-screen{width:100%;height:100%;border-radius:34px;overflow:hidden;background:#fff;color:#0f172a;display:flex;flex-direction:column}
    .ob-top{display:flex;justify-content:space-between;align-items:center;padding:13px 22px 0;font-size:13px;font-weight:700}
    .ob-skip{background:none;border:none;font-size:13px;font-weight:700;color:#94a3b8;cursor:pointer;font-family:inherit}
    
    .ob-track{flex:1;display:flex;overflow:hidden;transition:transform .4s cubic-bezier(.65,0,.35,1)}
    .ob-slide{min-width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:24px 30px}
    .ob-art{position:relative;width:150px;height:150px;display:flex;align-items:center;justify-content:center;margin-bottom:34px}
    .ob-blob{position:absolute;inset:0;border-radius:42% 58% 63% 37%/45% 38% 62% 55%;animation:obMorph 8s ease-in-out infinite}
    .a1 .ob-blob{background:linear-gradient(135deg,#a5b4fc,#6366f1)}
    .a2 .ob-blob{background:linear-gradient(135deg,#fcd34d,#f59e0b)}
    .a3 .ob-blob{background:linear-gradient(135deg,#6ee7b7,#10b981)}
    .ob-glyph{position:relative;font-size:52px;z-index:2}
    @keyframes obMorph{50%{border-radius:58% 42% 38% 62%/55% 62% 38% 45%}}
    .ob-slide h2{font-size:23px;font-weight:800;margin-bottom:10px}
    .ob-slide p{font-size:14px;color:#64748b;line-height:1.55;max-width:230px}
    
    .ob-foot{display:flex;align-items:center;justify-content:space-between;padding:18px 26px 26px}
    .ob-dots{display:flex;gap:7px}
    .ob-dots span{width:7px;height:7px;border-radius:99px;background:#e2e8f0;transition:width .3s,background .3s}
    .ob-dots span.on{width:22px;background:#6366f1}
    .ob-next{background:#6366f1;color:#fff;border:none;border-radius:12px;padding:12px 24px;font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;transition:background .15s}
    .ob-next:hover{background:#4f46e5}
  `]
})
export class MobileOnboardingScreensComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    var track = document.getElementById('obTrack');
    var dots = document.getElementById('obDots').children;
    var nextBtn = document.getElementById('obNext');
    var skip = document.getElementById('obSkip');
    var total = track.children.length;
    var index = 0;
    
    function go(i) {
      index = Math.max(0, Math.min(total - 1, i));
      track.style.transform = 'translateX(' + (-index * 100) + '%)';
      for (var d = 0; d < dots.length; d++) dots[d].classList.toggle('on', d === index);
      nextBtn.textContent = index === total - 1 ? 'Get started' : 'Next';
      skip.style.visibility = index === total - 1 ? 'hidden' : 'visible';
    }
    
    nextBtn.addEventListener('click', function () {
      if (index === total - 1) { nextBtn.textContent = 'Welcome! 🎉'; return; }
      go(index + 1);
    });
    skip.addEventListener('click', function () { go(total - 1); });
    
    // Allow swiping between slides.
    var startX = null;
    track.addEventListener('pointerdown', function (e) { startX = e.clientX; });
    window.addEventListener('pointerup', function (e) {
      if (startX === null) return;
      var dx = e.clientX - startX;
      if (dx < -45) go(index + 1); else if (dx > 45) go(index - 1);
      startX = null;
    });
    
    go(0);

    // Bind inline-handler functions to the component so the template can call them
    Object.assign(this, { go });
  }
}