Skip to content
UI Kit
Accent color

Section dividers

The seam between two sections in their own colors. Drifting waves, a slant with a drawing line, a labeled hairline or a gradient.

4 variantsOne of 5 components in Layouts

The seam between two sections

A section divider shapes the edge where one section ends and the next begins, in the colors of both. A hard color change becomes a deliberate transition, and the moving versions follow the scroll gently.

Wave layers three waves in the color above that drift against each other while the page scrolls. Slant sets a diagonal edge between two colors with a fine accent line that draws itself as it scrolls in. Line with label opens a hairline from the center around a label, for chapters or an or between two blocks. Gradient fades one color into the next while a soft light travels along the seam with the scroll.

Pick one divider style per site and repeat it, that makes it part of the brand. Waves suit friendly brands, the slant suits technical products. The labeled line works inside a section as well.

Variants

WaveNew in 1.2

Three layered waves in the color above that drift against each other while the page scrolls.

SlantNew in 1.2

A diagonal edge between two colors with a fine accent line that draws itself as it scrolls in.

Line with labelNew in 1.2

A hairline that opens from the center around a label, for chapters or an or between two blocks.

GradientNew in 1.2

One color fades into the next while a soft light travels along the seam with the scroll.

Details

What Section divider brings along, read from the registry and the source.

Category
Layouts
Area
Layout
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/section-divider.tsx
Brings along
utilskiticonmotion
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The SectionDividerProps interface from components/ui/section-divider.tsx, with the comments from the code.

  • variantoptional
    SectionDividerVariant

    waveslantlabelgradient

  • fromoptional
    DividerSurface

    backgroundsurfacemutedcardprimaryinverse

    Color of the section above

  • tooptional
    DividerSurface

    backgroundsurfacemutedcardprimaryinverse

    Color of the section below

  • sizeoptional
    'sm' | 'md' | 'lg'

    Height of the seam

  • flipoptional
    boolean

    wave and slant: mirror left and right

  • labeloptional
    string

    label: the text in the middle

  • iconoptional
    IconName

    label: an icon before the text

Accessibility

Read from the source of the component.

  • Sets aria-label, so screen readers announce purpose and state.
  • Uses the role separator.
  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 6 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 153 lines
components/ui/section-divider.tsx
'use client';

import { cn, useInView } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { useScrollProgress } from '@/components/ui/motion';

/*
 * The seam between two sections. It takes the color of the section above
 * (`from`) and the one below (`to`), so it fits between any two.
 *
 *   wave      layered waves that drift a little against each other while
 *             the page scrolls
 *   slant     a diagonal edge with a fine accent line that draws itself
 *   label     a hairline with a label in the middle, the line opens from
 *             the center, for chapters and an "or" between two blocks
 *   gradient  one color fades into the next, a light runs along the seam
 *
 * Purely decorative shapes are hidden from screen readers, the label is
 * read as a separator with its text.
 */

export type SectionDividerVariant = 'wave' | 'slant' | 'label' | 'gradient';
export type DividerSurface = 'background' | 'surface' | 'muted' | 'card' | 'primary' | 'inverse';

export interface SectionDividerProps {
  variant?: SectionDividerVariant;
  /** Color of the section above */
  from?: DividerSurface;
  /** Color of the section below */
  to?: DividerSurface;
  /** Height of the seam */
  size?: 'sm' | 'md' | 'lg';
  /** wave and slant: mirror left and right */
  flip?: boolean;
  /** label: the text in the middle */
  label?: string;
  /** label: an icon before the text */
  icon?: IconName;
  className?: string;
}

const COLOR: Record<DividerSurface, string> = {
  background: 'var(--background)',
  surface: 'var(--surface)',
  muted: 'var(--muted)',
  card: 'var(--card)',
  primary: 'var(--primary)',
  inverse: 'var(--foreground)',
};

const HEIGHT = { sm: 'h-12 sm:h-16', md: 'h-16 sm:h-24 lg:h-28', lg: 'h-24 sm:h-32 lg:h-40' } as const;

/* ------------------------------------------------------------------ */

function Wave({ from, to, size, flip }: Required<Pick<SectionDividerProps, 'from' | 'to' | 'size'>> & { flip?: boolean }) {
  const ref = useScrollProgress<HTMLDivElement>({ reach: false });
  // two periods wide, so a layer can slide by one period without an edge showing
  const wave = (amp: number, base: number, phase: number) => {
    const y = (x: number) => Math.round(base + amp * Math.sin(((x + phase) / 720) * Math.PI * 2));
    let d = `M0 0H2880V${y(2880)}`;
    for (let x = 2856; x >= 0; x -= 24) d += `L${x} ${y(x)}`;
    return d + 'Z';
  };
  const c = COLOR[from];
  return (
    <div ref={ref} aria-hidden data-clip-ok className={cn('relative w-full overflow-hidden', HEIGHT[size])} style={{ background: COLOR[to] }}>
      <svg viewBox="0 0 1440 120" preserveAspectRatio="none" className={cn('absolute inset-0 size-full', flip && '-scale-x-100')}>
        <g style={{ transform: 'translateX(calc((var(--kit-progress, 0.5) - 0.5) * -360px - 360px))' }} className="motion-reduce:!transform-none">
          <path d={wave(14, 74, 120)} style={{ fill: c, opacity: 0.35 }} />
        </g>
        <g style={{ transform: 'translateX(calc((var(--kit-progress, 0.5) - 0.5) * 240px - 360px))' }} className="motion-reduce:!transform-none">
          <path d={wave(18, 58, 0)} style={{ fill: c, opacity: 0.6 }} />
        </g>
        <path d={wave(20, 46, 260)} style={{ fill: c }} />
      </svg>
    </div>
  );
}

function Slant({ from, to, size, flip }: Required<Pick<SectionDividerProps, 'from' | 'to' | 'size'>> & { flip?: boolean }) {
  const ref = useScrollProgress<HTMLDivElement>({ start: 0.05, end: 0.6 });
  return (
    <div ref={ref} aria-hidden data-clip-ok className={cn('relative w-full overflow-hidden', HEIGHT[size])} style={{ background: COLOR[to] }}>
      <svg viewBox="0 0 1440 100" preserveAspectRatio="none" className={cn('absolute inset-0 size-full', flip && '-scale-x-100')}>
        <polygon points="0,0 1440,0 1440,4 0,100" style={{ fill: COLOR[from] }} />
        <line
          x1="0"
          y1="100"
          x2="1440"
          y2="4"
          pathLength={1}
          vectorEffect="non-scaling-stroke"
          strokeWidth="2"
          strokeLinecap="round"
          className="stroke-primary"
          style={{ strokeDasharray: 1, strokeDashoffset: 'calc(1 - var(--kit-progress, 1))' }}
        />
      </svg>
    </div>
  );
}

function Label({ from, label, icon }: Pick<SectionDividerProps, 'from' | 'label' | 'icon'>) {
  const [ref, inView] = useInView<HTMLDivElement>('0px 0px -15% 0px');
  const line = cn('h-px flex-1 transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'scale-x-0');
  return (
    <div ref={ref} role="separator" aria-label={label} className="w-full py-10 sm:py-14" style={{ background: COLOR[from ?? 'background'] }}>
      <div className="kit-container flex items-center gap-4 sm:gap-6">
        <span className={cn(line, 'origin-right bg-[linear-gradient(to_right,transparent,var(--border-strong)_45%)]')} />
        {label && (
          <span className={cn('inline-flex min-h-9 shrink-0 items-center gap-2 rounded-full border border-border bg-background px-4 text-[13px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-[opacity,transform] delay-300 duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'translate-y-1 opacity-0')}>
            {icon && <Icon name={icon} size={14} className="text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />}
            {label}
          </span>
        )}
        <span className={cn(line, 'origin-left bg-[linear-gradient(to_left,transparent,var(--border-strong)_45%)]')} />
      </div>
    </div>
  );
}

function Gradient({ from, to, size }: Required<Pick<SectionDividerProps, 'from' | 'to' | 'size'>>) {
  const ref = useScrollProgress<HTMLDivElement>({ start: 0.1, end: 0.9 });
  return (
    <div ref={ref} aria-hidden data-clip-ok className={cn('relative w-full overflow-hidden', HEIGHT[size])} style={{ background: `linear-gradient(to bottom, ${COLOR[from]}, ${COLOR[to]})` }}>
      <div className="absolute inset-x-0 top-1/2 h-px bg-[linear-gradient(to_right,transparent,color-mix(in_oklab,var(--primary)_35%,transparent),transparent)]" />
      {/* a light that travels along the seam with the scroll */}
      <div className="absolute top-1/2 h-px w-[40%] -translate-y-px motion-reduce:hidden" style={{ left: 'calc(var(--kit-progress, 0.5) * 80% - 10%)', background: 'linear-gradient(to right, transparent, var(--primary), transparent)' }} />
      <div className="absolute top-1/2 h-16 w-[30%] -translate-y-1/2 rounded-full opacity-60 blur-2xl motion-reduce:hidden" style={{ left: 'calc(var(--kit-progress, 0.5) * 80% - 5%)', background: 'radial-gradient(closest-side, var(--glow), transparent)' }} />
    </div>
  );
}

/**
 * The seam between two sections: waves, a slant with a drawing line, a
 * labeled hairline or a gradient with a travelling light.
 *   <SectionDivider variant="wave" from="surface" to="background" />
 */
export function SectionDivider({ variant = 'wave', from = 'surface', to = 'background', size = 'md', flip, label, icon, className }: SectionDividerProps) {
  return (
    <div className={cn('relative', className)}>
      {variant === 'slant' ? (
        <Slant from={from} to={to} size={size} flip={flip} />
      ) : variant === 'label' ? (
        <Label from={from} label={label} icon={icon} />
      ) : variant === 'gradient' ? (
        <Gradient from={from} to={to} size={size} />
      ) : (
        <Wave from={from} to={to} size={size} flip={flip} />
      )}
    </div>
  );
}

Frequent questions

How do I install Section divider?

With aiw add @ai-website/section-divider. The command puts components/ui/section-divider.tsx into your project, the code is yours from then on. It brings along icon and motion. It needs no further npm packages.

Which variants does Section divider have?

4 variants, Wave, Slant, Line with label and Gradient. You pick one with the variant prop, for example variant="slant".

Can an AI assistant build with Section divider?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Section divider with aiw_components and install it with aiw_add @ai-website/section-divider. Name the variant in your prompt, for example variant="wave", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard