Web InnoventixFreeCode

Vertical Divider

Original · free

vertical dividers

byWeb InnoventixReact + Tailwind
divverticaldividers
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/div-vertical.json
div-vertical.tsx
"use client";

import {
  Fragment,
  useCallback,
  useId,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

const VARIANTS = [
  { id: "hairline", label: "Hairline", hint: "1px, quiet" },
  { id: "gradient", label: "Gradient", hint: "fades at both ends" },
  { id: "dashed", label: "Dashed", hint: "a moving rhythm" },
  { id: "beaded", label: "Beaded", hint: "a column of dots" },
  { id: "glow", label: "Glow", hint: "accent, pulsing" },
] as const;

type DividerVariant = (typeof VARIANTS)[number]["id"];

const MIN = 22;
const MAX = 78;
const STEP = 2;

const METRICS = [
  { value: "1.4s", label: "Median load" },
  { value: "98", label: "Lighthouse" },
  { value: "24", label: "Components" },
  { value: "0", label: "Layout shifts" },
];

function VerticalLine({
  variant,
  reduced,
}: {
  variant: DividerVariant;
  reduced: boolean;
}) {
  switch (variant) {
    case "hairline":
      return <span className="h-full w-px bg-slate-300 dark:bg-slate-700" />;
    case "gradient":
      return (
        <span className="h-full w-px bg-gradient-to-b from-transparent via-indigo-400 to-transparent dark:via-indigo-500" />
      );
    case "dashed":
      return (
        <span
          className={`h-full w-0.5 text-slate-300 dark:text-slate-600 ${
            reduced ? "" : "dvv-dash-flow"
          }`}
          style={{
            backgroundImage:
              "repeating-linear-gradient(to bottom, currentColor 0 6px, transparent 6px 12px)",
          }}
        />
      );
    case "beaded":
      return (
        <span
          className="h-full w-1 text-slate-400 dark:text-slate-500"
          style={{
            backgroundImage:
              "radial-gradient(currentColor 45%, transparent 50%)",
            backgroundSize: "4px 10px",
            backgroundRepeat: "repeat-y",
            backgroundPosition: "center",
          }}
        />
      );
    case "glow":
      return (
        <span
          className={`h-full w-px bg-violet-400 ${
            reduced ? "" : "dvv-glow-line"
          }`}
          style={{ boxShadow: "0 0 8px 1px rgba(139, 92, 246, 0.7)" }}
        />
      );
  }
}

export default function VerticalDividerLab() {
  const prefersReduced = useReducedMotion() ?? false;
  const [pos, setPos] = useState(50);
  const [dragging, setDragging] = useState(false);
  const [variant, setVariant] = useState<DividerVariant>("gradient");

  const containerRef = useRef<HTMLDivElement | null>(null);
  const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const groupLabelId = useId();
  const separatorLabelId = useId();

  const rounded = Math.round(pos);

  const updateFromClientX = useCallback((clientX: number) => {
    const el = containerRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const pct = ((clientX - rect.left) / rect.width) * 100;
    setPos(Math.min(MAX, Math.max(MIN, pct)));
  }, []);

  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.currentTarget.setPointerCapture(e.pointerId);
    setDragging(true);
  };

  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!dragging) return;
    updateFromClientX(e.clientX);
  };

  const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (e.currentTarget.hasPointerCapture(e.pointerId)) {
      e.currentTarget.releasePointerCapture(e.pointerId);
    }
    setDragging(false);
  };

  const onSeparatorKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    let next = pos;
    switch (e.key) {
      case "ArrowLeft":
        next = pos - STEP;
        break;
      case "ArrowRight":
        next = pos + STEP;
        break;
      case "PageDown":
        next = pos - STEP * 4;
        break;
      case "PageUp":
        next = pos + STEP * 4;
        break;
      case "Home":
        next = MIN;
        break;
      case "End":
        next = MAX;
        break;
      default:
        return;
    }
    e.preventDefault();
    setPos(Math.min(MAX, Math.max(MIN, next)));
  };

  const onRadioKeyDown = (
    e: ReactKeyboardEvent<HTMLButtonElement>,
    index: number,
  ) => {
    const count = VARIANTS.length;
    let next = index;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        next = (index + 1) % count;
        break;
      case "ArrowLeft":
      case "ArrowUp":
        next = (index - 1 + count) % count;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = count - 1;
        break;
      default:
        return;
    }
    e.preventDefault();
    setVariant(VARIANTS[next].id);
    radioRefs.current[next]?.focus();
  };

  const rise = (delay: number) =>
    prefersReduced
      ? {}
      : {
          initial: { opacity: 0, y: 18 },
          whileInView: { opacity: 1, y: 0 },
          viewport: { once: true, margin: "-80px" },
          transition: { duration: 0.55, delay, ease: "easeOut" as const },
        };

  return (
    <section className="relative w-full bg-white px-5 py-20 text-slate-900 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes dvv-dash-flow { to { background-position: 0 12px; } }
        @keyframes dvv-glow-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
        .dvv-dash-flow { animation: dvv-dash-flow 1.1s linear infinite; }
        .dvv-glow-line { animation: dvv-glow-pulse 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .dvv-dash-flow, .dvv-glow-line { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <motion.div {...rise(0)} className="max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium uppercase tracking-widest text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <svg
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth={2}
              className="h-3.5 w-3.5"
              aria-hidden="true"
            >
              <rect x="3" y="4" width="7" height="16" rx="1.5" />
              <rect x="14" y="4" width="7" height="16" rx="1.5" />
            </svg>
            Dividers / Vertical
          </span>
          <h2 className="mt-5 text-3xl font-semibold tracking-tight sm:text-4xl">
            Split a row without drawing a box
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A vertical divider does the quiet work of separation — grouping
            columns, pacing a stat bar, and marking where one idea ends and the
            next begins. Drag the handle, then try each style below.
          </p>
        </motion.div>

        <motion.div {...rise(0.08)} className="mt-10">
          <div className="mb-3 flex items-center justify-between gap-3">
            <span className="inline-flex items-center gap-1.5 text-sm font-medium tabular-nums text-slate-500 dark:text-slate-400">
              <span className="rounded-md bg-slate-100 px-2 py-0.5 text-slate-700 dark:bg-slate-800 dark:text-slate-200">
                {rounded}
              </span>
              <span aria-hidden="true">/</span>
              <span className="rounded-md bg-slate-100 px-2 py-0.5 text-slate-700 dark:bg-slate-800 dark:text-slate-200">
                {100 - rounded}
              </span>
            </span>
            <button
              type="button"
              onClick={() => setPos(50)}
              className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-800 dark:text-slate-300 dark:hover:bg-slate-900 dark:focus-visible:ring-offset-slate-950"
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-3.5 w-3.5"
                aria-hidden="true"
              >
                <path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
                <path d="M3 3v5h5" />
              </svg>
              Reset to 50 / 50
            </button>
          </div>

          <div
            ref={containerRef}
            className={`relative grid h-72 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm sm:h-80 dark:border-slate-800 dark:bg-slate-900 ${
              dragging ? "select-none" : ""
            }`}
            style={{ gridTemplateColumns: `${pos}% ${100 - pos}%` }}
          >
            <div className="min-w-0 overflow-hidden bg-slate-50/70 p-5 pr-8 sm:p-6 sm:pr-8 dark:bg-slate-900/60">
              <span className="text-xs font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
                Structure
              </span>
              <h3 className="mt-2 text-lg font-semibold">
                Group what belongs together
              </h3>
              <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                A divider is a promise: the block on the other side is a new
                thought, not a continuation of this one. Fewer borders, clearer
                intent.
              </p>
            </div>

            <div className="min-w-0 overflow-hidden bg-white p-5 pl-8 sm:p-6 sm:pl-8 dark:bg-slate-900">
              <span className="text-xs font-semibold uppercase tracking-widest text-emerald-600 dark:text-emerald-400">
                Rhythm
              </span>
              <h3 className="mt-2 text-lg font-semibold">
                Give the eye a place to rest
              </h3>
              <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                Vertical rules carve a row into scannable columns without adding
                a single card or heavy outline. Rhythm, not clutter.
              </p>
            </div>

            <div
              role="separator"
              aria-orientation="vertical"
              aria-labelledby={separatorLabelId}
              aria-valuenow={rounded}
              aria-valuemin={MIN}
              aria-valuemax={MAX}
              aria-valuetext={`${rounded}% left, ${100 - rounded}% right`}
              tabIndex={0}
              onPointerDown={onPointerDown}
              onPointerMove={onPointerMove}
              onPointerUp={onPointerUp}
              onPointerCancel={onPointerUp}
              onKeyDown={onSeparatorKeyDown}
              onDoubleClick={() => setPos(50)}
              data-dragging={dragging ? "true" : "false"}
              className="group absolute inset-y-0 z-10 flex w-6 -translate-x-1/2 cursor-col-resize touch-none items-center justify-center focus-visible:outline-none"
              style={{ left: `${pos}%` }}
            >
              <span id={separatorLabelId} className="sr-only">
                Resize panels. Use the left and right arrow keys to adjust.
              </span>
              <span className="pointer-events-none absolute inset-y-0 left-1/2 flex -translate-x-1/2 items-stretch">
                <VerticalLine variant={variant} reduced={prefersReduced} />
              </span>
              <span className="pointer-events-none relative z-10 flex h-10 w-5 items-center justify-center rounded-full border border-slate-200 bg-white shadow-sm transition-transform group-hover:scale-105 group-focus-visible:ring-2 group-focus-visible:ring-indigo-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-white group-data-[dragging=true]:scale-110 group-data-[dragging=true]:border-indigo-400 dark:border-slate-700 dark:bg-slate-800 dark:group-focus-visible:ring-offset-slate-900 dark:group-data-[dragging=true]:border-indigo-500">
                <span className="grid grid-cols-2 gap-1">
                  {Array.from({ length: 6 }).map((_, i) => (
                    <span
                      key={i}
                      className="h-1 w-1 rounded-full bg-slate-400 dark:bg-slate-500"
                    />
                  ))}
                </span>
              </span>
            </div>
          </div>

          <p className="mt-3 text-xs text-slate-500 dark:text-slate-500">
            Drag the handle, or focus it and press{" "}
            <kbd className="rounded border border-slate-300 px-1 font-sans dark:border-slate-700">
              ←
            </kbd>{" "}
            <kbd className="rounded border border-slate-300 px-1 font-sans dark:border-slate-700">
              →
            </kbd>{" "}
            · Home / End to snap · double-click to re-center.
          </p>
        </motion.div>

        <motion.div {...rise(0.16)} className="mt-12">
          <h3
            id={groupLabelId}
            className="text-sm font-semibold uppercase tracking-widest text-slate-500 dark:text-slate-400"
          >
            Divider style
          </h3>
          <div
            role="radiogroup"
            aria-labelledby={groupLabelId}
            className="mt-4 flex flex-wrap gap-3"
          >
            {VARIANTS.map((v, i) => {
              const selected = v.id === variant;
              return (
                <button
                  key={v.id}
                  ref={(node) => {
                    radioRefs.current[i] = node;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={selected}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => setVariant(v.id)}
                  onKeyDown={(e) => onRadioKeyDown(e, i)}
                  className={`flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${
                    selected
                      ? "border-indigo-500 bg-indigo-50 dark:border-indigo-500 dark:bg-indigo-500/10"
                      : "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700"
                  }`}
                >
                  <span className="flex h-8 w-3 items-stretch justify-center">
                    <VerticalLine variant={v.id} reduced={prefersReduced} />
                  </span>
                  <span className="min-w-0">
                    <span
                      className={`block text-sm font-semibold ${
                        selected
                          ? "text-indigo-700 dark:text-indigo-300"
                          : "text-slate-800 dark:text-slate-200"
                      }`}
                    >
                      {v.label}
                    </span>
                    <span className="block text-xs text-slate-500 dark:text-slate-400">
                      {v.hint}
                    </span>
                  </span>
                </button>
              );
            })}
          </div>
        </motion.div>

        <motion.div {...rise(0.24)} className="mt-12">
          <div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
            <div className="flex items-stretch justify-between gap-1 sm:gap-2">
              {METRICS.map((m, i) => (
                <Fragment key={m.label}>
                  {i > 0 && (
                    <div className="flex w-3 items-stretch justify-center py-1">
                      <VerticalLine
                        variant={variant}
                        reduced={prefersReduced}
                      />
                    </div>
                  )}
                  <div className="flex flex-1 flex-col items-center justify-center px-1 text-center">
                    <span className="text-2xl font-semibold tabular-nums tracking-tight sm:text-3xl">
                      {m.value}
                    </span>
                    <span className="mt-1 text-[11px] font-medium uppercase tracking-wide text-slate-500 sm:text-xs dark:text-slate-400">
                      {m.label}
                    </span>
                  </div>
                </Fragment>
              ))}
            </div>
          </div>
          <p className="mt-3 text-xs text-slate-500 dark:text-slate-500">
            The same divider style keeps a metric bar readable — one rule
            between numbers, no boxes.
          </p>
        </motion.div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →