Vue componentFree

Feature Alternating

Alternating Vue 3 feature rows that zigzag text and CSS-drawn illustrations, with bullets and links per row. Tailwind CSS, dark mode, no images needed.

by Vueplayv1.0.0Updated 2 hours ago

Install
Full screen ↗

About this component

A zigzag product tour: each row pairs a short pitch (eyebrow, title, paragraph, bullet list and an optional link) with an illustration, and the sides swap on every row so the page has rhythm as visitors scroll.

Four illustrations are built in and drawn with Tailwind utilities: a weekly schedule, a chat thread, an analytics bar chart and an onboarding checklist. They theme with dark mode and never go blurry. Replace any of them with a real screenshot through the scoped illustration slot, which receives the row and its index.

On phones every row stacks with the text first, so the story reads in order. Set startWithImage to flip which side the first illustration appears on.

  • Zigzag layout that stacks on mobile
  • 4 CSS-drawn illustrations
  • Bullets and optional link per row
  • Scoped illustration slot
  • Light and dark mode

Code

11 files in the repo
<script setup lang="ts">
/** Built-in drawn illustrations. Use the `illustration` slot for your own art or screenshots. */
export type FeatureIllustration = 'schedule' | 'chat' | 'analytics' | 'checklist'

export interface FeatureRow {
  eyebrow?: string
  title: string
  description: string
  bullets?: string[]
  link?: { label: string, href: string }
  illustration?: FeatureIllustration
}

withDefaults(defineProps<{
  heading?: string
  description?: string
  rows?: FeatureRow[]
  /** Put the first row's illustration on the left instead of the right. */
  startWithImage?: boolean
}>(), {
  heading: 'Scheduling your team will actually read',
  description: 'Rotawise replaces the spreadsheet taped to the fridge with a rota that builds itself, fills its own gaps and keeps labour costs in check.',
  startWithImage: false,
  rows: () => [
    {
      eyebrow: 'Plan',
      title: 'Build next week’s rota in minutes',
      description: 'Drag shifts onto the week, copy last week in one click, and let Rotawise warn you about overtime or missing cover before you publish.',
      bullets: ['Templates for recurring shifts', 'Availability and time-off built in', 'Publish to everyone’s phone instantly'],
      link: { label: 'See the rota builder', href: '#builder' },
      illustration: 'schedule',
    },
    {
      eyebrow: 'Swap',
      title: 'Shift swaps without the group chat',
      description: 'Staff offer and pick up shifts themselves. You approve with one tap, and the rota updates for everyone at the same time.',
      bullets: ['Only qualified staff can pick up', 'Manager approval optional', 'Full history of every change'],
      illustration: 'chat',
    },
    {
      eyebrow: 'Measure',
      title: 'Know your labour cost before payday',
      description: 'See wages as a percentage of sales for every day and location, and spot the expensive Tuesdays long before the month closes.',
      bullets: ['Live cost per shift and per day', 'Export straight to payroll'],
      link: { label: 'Explore reports', href: '#reports' },
      illustration: 'analytics',
    },
  ],
})

defineSlots<{
  illustration?: (props: { row: FeatureRow, index: number }) => unknown
}>()

const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
// [dayIndex, startCol, span, tone]
const shifts: [number, number, number, 'a' | 'b' | 'c'][] = [
  [0, 1, 3, 'a'], [0, 4, 3, 'b'], [1, 2, 4, 'c'], [2, 1, 2, 'a'], [2, 3, 4, 'b'], [3, 1, 5, 'c'], [4, 2, 2, 'a'], [4, 4, 3, 'b'],
]
const tones = {
  a: 'bg-amber-400/90 dark:bg-amber-400/80',
  b: 'bg-amber-200 dark:bg-amber-600/60',
  c: 'bg-gray-300 dark:bg-gray-600',
}
const bars = [42, 58, 50, 72, 64, 88, 70]
</script>

<template>
  <section class="px-6 py-20 sm:py-24 lg:px-8">
    <div v-if="heading || description" class="mx-auto max-w-2xl text-center">
      <h2 class="text-3xl font-bold tracking-tight text-balance text-gray-900 sm:text-4xl dark:text-white">{{ heading }}</h2>
      <p v-if="description" class="mt-4 text-lg text-pretty text-gray-600 dark:text-gray-400">{{ description }}</p>
    </div>

    <div class="mx-auto mt-16 max-w-6xl space-y-20 sm:mt-20 lg:space-y-28">
      <article
        v-for="(row, index) in rows"
        :key="row.title"
        class="grid items-center gap-10 lg:grid-cols-2 lg:gap-16"
      >
        <div :class="(index % 2 === 1) !== startWithImage ? 'lg:order-2' : ''">
          <p v-if="row.eyebrow" class="text-sm font-semibold text-amber-700 dark:text-amber-400">{{ row.eyebrow }}</p>
          <h3 class="mt-2 text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl dark:text-white">{{ row.title }}</h3>
          <p class="mt-4 text-base leading-7 text-gray-600 dark:text-gray-400">{{ row.description }}</p>
          <ul v-if="row.bullets?.length" class="mt-6 space-y-3 text-sm text-gray-700 dark:text-gray-300">
            <li v-for="bullet in row.bullets" :key="bullet" class="flex gap-3">
              <svg viewBox="0 0 20 20" aria-hidden="true" class="size-5 shrink-0 fill-amber-600 dark:fill-amber-400"><path fill-rule="evenodd" d="M16.7 5.3a1 1 0 0 1 0 1.4l-8 8a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.4L8 12.6l7.3-7.3a1 1 0 0 1 1.4 0Z" clip-rule="evenodd" /></svg>
              {{ bullet }}
            </li>
          </ul>
          <a
            v-if="row.link"
            :href="row.link.href"
            class="mt-8 inline-flex items-center gap-1 text-sm font-semibold text-amber-700 hover:text-amber-600 focus-visible:rounded focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 dark:text-amber-400 dark:hover:text-amber-300"
          >
            {{ row.link.label }} <span aria-hidden="true">→</span>
          </a>
        </div>

        <div :class="(index % 2 === 1) !== startWithImage ? 'lg:order-1' : ''">
          <slot name="illustration" :row="row" :index="index">
            <div aria-hidden="true" class="relative overflow-hidden rounded-3xl bg-gradient-to-br from-amber-50 to-orange-100 p-6 ring-1 ring-amber-900/5 sm:p-10 dark:from-amber-500/10 dark:to-orange-500/5 dark:ring-white/10">
              <!-- Schedule: a week grid with shift blocks -->
              <div v-if="row.illustration === 'schedule'" class="rounded-2xl bg-white p-4 shadow-xl shadow-amber-900/10 sm:p-5 dark:bg-gray-900 dark:shadow-black/30">
                <div class="flex items-center justify-between">
                  <span class="text-sm font-semibold text-gray-900 dark:text-white">Week 42</span>
                  <span class="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-400/15 dark:text-amber-300">Draft</span>
                </div>
                <div class="mt-4 space-y-2.5">
                  <div v-for="(day, d) in days" :key="day" class="grid grid-cols-[2.5rem_1fr] items-center gap-2">
                    <span class="text-xs text-gray-500 dark:text-gray-400">{{ day }}</span>
                    <div class="grid h-6 grid-cols-8 gap-1 rounded-md bg-gray-50 p-0.5 dark:bg-gray-800/60">
                      <span
                        v-for="(s, i) in shifts.filter(x => x[0] === d)"
                        :key="i"
                        class="rounded"
                        :class="tones[s[3]]"
                        :style="{ gridColumn: `${s[1]} / span ${s[2]}` }"
                      />
                    </div>
                  </div>
                </div>
                <div class="mt-4 flex items-center gap-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:bg-amber-400/10 dark:text-amber-200">
                  <span class="size-1.5 rounded-full bg-amber-500" />
                  Thursday is 1 person short at close
                </div>
              </div>

              <!-- Chat: a swap request conversation -->
              <div v-else-if="row.illustration === 'chat'" class="mx-auto max-w-sm space-y-3">
                <div class="flex items-end gap-2">
                  <span class="grid size-8 shrink-0 place-items-center rounded-full bg-gray-800 text-[11px] font-semibold text-white dark:bg-gray-700">NP</span>
                  <div class="rounded-2xl rounded-bl-sm bg-white px-4 py-3 text-sm text-gray-800 shadow-md dark:bg-gray-900 dark:text-gray-200">
                    Can anyone take my Saturday close? Family thing.
                  </div>
                </div>
                <div class="ml-10 rounded-2xl bg-white p-3 shadow-md dark:bg-gray-900">
                  <div class="flex items-center justify-between text-xs">
                    <span class="font-semibold text-gray-900 dark:text-white">Sat · 16:00–23:00</span>
                    <span class="text-gray-500 dark:text-gray-400">Bar</span>
                  </div>
                  <div class="mt-2 h-1.5 rounded-full bg-amber-400" />
                </div>
                <div class="flex flex-row-reverse items-end gap-2">
                  <span class="grid size-8 shrink-0 place-items-center rounded-full bg-amber-500 text-[11px] font-semibold text-white">JD</span>
                  <div class="rounded-2xl rounded-br-sm bg-amber-500 px-4 py-3 text-sm text-white shadow-md dark:bg-amber-500 dark:text-gray-950">
                    I’ve got it, enjoy your weekend!
                  </div>
                </div>
                <div class="flex justify-center">
                  <span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-800 dark:bg-emerald-400/15 dark:text-emerald-300">
                    <svg viewBox="0 0 20 20" class="size-3.5 fill-current"><path fill-rule="evenodd" d="M16.7 5.3a1 1 0 0 1 0 1.4l-8 8a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.4L8 12.6l7.3-7.3a1 1 0 0 1 1.4 0Z" clip-rule="evenodd" /></svg>
                    Swap approved by manager
                  </span>
                </div>
              </div>

              <!-- Analytics: bar chart with a target line -->
              <div v-else-if="row.illustration === 'analytics'" class="rounded-2xl bg-white p-5 shadow-xl shadow-amber-900/10 dark:bg-gray-900 dark:shadow-black/30">
                <div class="flex items-start justify-between">
                  <div>
                    <p class="text-xs text-gray-500 dark:text-gray-400">Labour cost / sales</p>
                    <p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">27.4%</p>
                  </div>
                  <span class="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800 dark:bg-emerald-400/15 dark:text-emerald-300">−2.1 pts</span>
                </div>
                <div class="relative mt-6 flex h-36 items-end gap-2 sm:gap-3">
                  <div class="absolute inset-x-0 top-[30%] border-t border-dashed border-amber-500/70" />
                  <div v-for="(h, i) in bars" :key="i" class="flex-1 rounded-t-md" :class="i === 5 ? 'bg-amber-500' : 'bg-amber-200 dark:bg-amber-500/30'" :style="{ height: `${h}%` }" />
                </div>
                <div class="mt-2 flex justify-between text-[10px] text-gray-400">
                  <span v-for="(d, i) in ['M', 'T', 'W', 'T', 'F', 'S', 'S']" :key="i">{{ d }}</span>
                </div>
              </div>

              <!-- Checklist -->
              <div v-else class="space-y-3 rounded-2xl bg-white p-5 shadow-xl shadow-amber-900/10 dark:bg-gray-900 dark:shadow-black/30">
                <div v-for="(t, i) in ['Import staff list', 'Set opening hours', 'Publish first rota', 'Invite managers']" :key="t" class="flex items-center gap-3">
                  <span class="grid size-5 place-items-center rounded-full" :class="i < 2 ? 'bg-amber-500 text-white' : 'ring-1 ring-gray-300 dark:ring-gray-600'">
                    <svg v-if="i < 2" viewBox="0 0 20 20" class="size-3 fill-current"><path fill-rule="evenodd" d="M16.7 5.3a1 1 0 0 1 0 1.4l-8 8a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.4L8 12.6l7.3-7.3a1 1 0 0 1 1.4 0Z" clip-rule="evenodd" /></svg>
                  </span>
                  <span class="text-sm" :class="i < 2 ? 'text-gray-400 line-through' : 'text-gray-800 dark:text-gray-200'">{{ t }}</span>
                </div>
              </div>
            </div>
          </slot>
        </div>
      </article>
    </div>
  </section>
</template>

Props

PropTypeDefaultDescription
headingstring'Scheduling your team will actually read'Section heading. Empty string hides the intro.
descriptionstring'Rotawise replaces the spreadsheet…'Intro paragraph under the heading.
rowsFeatureRow[]Plan, Swap, MeasureRows with eyebrow, title, description, bullets, optional link and a built-in illustration name ('schedule' | 'chat' | 'analytics' | 'checklist').
startWithImagebooleanfalseShow the first row's illustration on the left on desktop.

Slots

SlotDescription
illustrationReplaces the drawn illustration. Receives { row, index }.
All feature sections →
Preview of Bento GridFree

Component · Feature sections

Bento Grid

Bento-style Vue 3 feature grid with wide and tall tiles and small CSS-drawn visuals like maps, timelines and code. Tailwind CSS, dark mode, typed tiles.

by Vueplay
Preview of Feature GridFree

Component · Feature sections

Feature Grid

Responsive Vue 3 feature grid with a heading, inline SVG icons and plain or card variants in 2, 3 or 4 columns. Tailwind CSS, dark mode, typed data.

by Vueplay
Preview of Logo CloudFree

Component · Feature sections

Logo Cloud

"Trusted by" logo cloud for Vue 3 with a bordered grid or an endless marquee, using text wordmarks and SVG marks. Tailwind CSS, dark mode, reduced motion.

by Vueplay
Preview of Hero SplitFree

Component · Hero sections

Hero Split

Split Vue 3 hero section with copy and two CTAs on the left and a Tailwind-drawn app window mockup on the right. Dark mode, typed props, no images.

by Vueplay1 installs

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings