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.
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
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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SectionDividerProps interface from components/ui/section-divider.tsx, with the comments from the code.
variantoptionalSectionDividerVariantwaveslantlabelgradient
fromoptionalDividerSurfacebackgroundsurfacemutedcardprimaryinverse
Color of the section above
tooptionalDividerSurfacebackgroundsurfacemutedcardprimaryinverse
Color of the section below
sizeoptional'sm' | 'md' | 'lg'Height of the seam
flipoptionalbooleanwave and slant: mirror left and right
labeloptionalstringlabel: the text in the middle
iconoptionalIconNamelabel: 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
'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.