# Feature Section v10

> Two-column SaaS feature section with dashed corner-accent cards and a morphing wireframe preview that updates on card hover.

A shadcn/ui block from the [@uiception](https://uiception.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiception/feature-section-v10).

- Page, with a live preview: https://shadcn.exchange/r/uiception/feature-section-v10
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uiception/feature-section-v10-light-64d4bb9388b2a74a.png
- Registry item: https://uiception.com/r/feature-section-v10.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uiception.com/r/feature-section-v10.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `gsap`, `motion`

## Files

### `registry/new-york/blocks/feature-section-v10/page.tsx`

```tsx
import FeatureSectionV10 from "./components/feature-section-v10"

export default function Page() {
  return (
    <div className="p-4">
      <FeatureSectionV10 />
    </div>
  )
}
```

### `registry/new-york/blocks/feature-section-v10/components/feature-section-v10.tsx`

```tsx
import FeatureShowcase from "./feature-showcase"

export default function FeatureSectionV10() {
  return (
    <section className="py-4 md:py-6 lg:py-8">
      <div className="mx-auto max-w-7xl px-4 sm:px-6">
        <FeatureShowcase />
      </div>
    </section>
  )
}
```

### `registry/new-york/blocks/feature-section-v10/components/feature-showcase.tsx`

```tsx
"use client"

import { useState } from "react"

import {
  featureSectionItems,
  featureSectionTitle,
  type FeatureIconId,
} from "../lib/features"
import { useHoverIntent } from "../hooks/use-hover-intent"
import FeatureCard from "./feature-card"
import FeaturePanel from "./feature-panel"

export default function FeatureShowcase() {
  const [activeIcon, setActiveIcon] = useState<FeatureIconId>(
    featureSectionItems[0]?.icon ?? "flow"
  )
  const { schedule, commit } = useHoverIntent(setActiveIcon)

  return (
    <div className="flex flex-col gap-5 lg:gap-6">
      <header className="max-w-2xl">
        <h2 className="m-0 text-2xl font-medium leading-snug tracking-[-0.02em] text-balance sm:text-3xl sm:leading-[1.2] lg:text-[2rem] lg:leading-[1.2]">
          {featureSectionTitle}
        </h2>
      </header>

      <div className="flex flex-col gap-5 lg:grid lg:grid-cols-2 lg:items-stretch lg:gap-8 xl:gap-10">
        <ul className="m-0 flex min-w-0 list-none flex-col gap-2.5 p-0 sm:gap-3">
          {featureSectionItems.map((item) => (
            <li key={item.id} className="m-0 p-0">
              <FeatureCard
                item={item}
                active={activeIcon === item.icon}
                onHoverIntent={() => schedule(item.icon)}
                onActivate={() => commit(item.icon)}
              />
            </li>
          ))}
        </ul>

        <div className="min-w-0 w-full">
          <FeaturePanel activeIcon={activeIcon} />
        </div>
      </div>
    </div>
  )
}
```

### `registry/new-york/blocks/feature-section-v10/components/feature-card.tsx`

```tsx
import type { ComponentType } from "react"

import { cn } from "@/lib/utils"

import type { FeatureItem } from "../lib/features"
import { FeatureCardCorners } from "./feature-card-corners"

export default function FeatureCard({
  item,
  active,
  onHoverIntent,
  onActivate,
}: {
  item: FeatureItem
  active: boolean
  /** Debounced hover — used so sweeping cards does not thrash the morph. */
  onHoverIntent: () => void
  /** Immediate commit — focus / click for a11y. */
  onActivate: () => void
}) {
  return (
    <button
      type="button"
      onMouseEnter={onHoverIntent}
      onFocus={onActivate}
      onClick={onActivate}
      className={cn(
        "relative w-full cursor-pointer border border-dashed bg-transparent p-4 text-left motion-safe:transition-colors duration-200 sm:p-5",
        "border-border/80 hover:border-primary/45 focus-visible:border-primary/55 focus-visible:outline-none",
        active && "border-primary/50 bg-primary/3"
      )}
      aria-pressed={active}
    >
      <FeatureCardCorners active={active} />
      <div className="flex flex-col gap-1">
        <h3
          className={cn(
            "m-0 text-[0.9375rem] font-medium leading-snug tracking-[-0.01em] text-foreground sm:text-base",
            active && "text-primary"
          )}
        >
          {item.title}
        </h3>
        <p className="m-0 text-[0.8125rem] leading-relaxed text-muted-foreground sm:text-sm">
          {item.description}
        </p>
      </div>
    </button>
  )
}

export type FeatureIllustrationComponent = ComponentType<{
  active?: boolean
  className?: string
}>
```

### `registry/new-york/blocks/feature-section-v10/components/feature-card-corners.tsx`

```tsx
import { cn } from "@/lib/utils"

const arm =
  "absolute size-3 border-primary/40 motion-safe:transition-colors duration-200"

export function FeatureCardCorners({ active }: { active?: boolean }) {
  return (
    <>
      <span
        className={cn(arm, "top-0 left-0 -translate-x-px -translate-y-px border-t-[1.5px] border-l-[1.5px]", active && "border-primary")}
        aria-hidden
      />
      <span
        className={cn(arm, "top-0 right-0 translate-x-px -translate-y-px border-t-[1.5px] border-r-[1.5px]", active && "border-primary")}
        aria-hidden
      />
      <span
        className={cn(arm, "bottom-0 left-0 -translate-x-px translate-y-px border-b-[1.5px] border-l-[1.5px]", active && "border-primary")}
        aria-hidden
      />
      <span
        className={cn(arm, "right-0 bottom-0 translate-x-px translate-y-px border-r-[1.5px] border-b-[1.5px]", active && "border-primary")}
        aria-hidden
      />
    </>
  )
}
```

### `registry/new-york/blocks/feature-section-v10/components/feature-panel.tsx`

```tsx
"use client"

import { cn } from "@/lib/utils"

import type { FeatureIconId } from "../lib/features"
import GeoWireframe from "./geo-wireframe"

export default function FeaturePanel({
  activeIcon,
  className,
}: {
  activeIcon: FeatureIconId
  className?: string
}) {
  return (
    <div
      className={cn(
        "relative h-80 w-full min-w-0 overflow-hidden rounded-lg sm:h-96 lg:h-full",
        className
      )}
      aria-live="polite"
    >
      <GeoWireframe icon={activeIcon} active className="size-full min-h-full" />
    </div>
  )
}
```

### `registry/new-york/blocks/feature-section-v10/components/geo-wireframe.tsx`

```tsx
"use client"

import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react"
import { useReducedMotion } from "motion/react"

import { cn } from "@/lib/utils"

import type { FeatureIconId } from "../lib/features"
import { featureIllustrationViewBoxes } from "../lib/features"
import { useGeoMorph } from "../hooks/use-geo-morph"
import {
  projectVerticesInto,
  unifiedMesh,
  type ProjectedPoint,
  type Vec3,
} from "../lib/geo-mesh"

import "../styles/feature-section-v10.css"

type GeoWireframeProps = {
  icon: FeatureIconId
  active?: boolean
  className?: string
}

const NODE_COUNT = unifiedMesh.vertices.length
const EDGE_COUNT = unifiedMesh.edges.length

function MeshScene({
  active,
  morphingRef,
  filterId,
  verticesRef,
}: {
  active: boolean
  morphingRef: RefObject<boolean>
  filterId: string
  verticesRef: RefObject<Vec3[]>
}) {
  const reduceMotion = useReducedMotion()
  const groupRef = useRef<SVGGElement | null>(null)
  const lineRefs = useRef<(SVGLineElement | null)[]>([])
  const bokehRefs = useRef<(SVGCircleElement | null)[]>([])
  const nodeRefs = useRef<(SVGCircleElement | null)[]>([])

  useEffect(() => {
    const projected: ProjectedPoint[] = []

    let raf = 0
    let rotation = 0
    let lastStrokeWidth = -1
    let lastOpacityScale = -1

    const render = (rotation: number) => {
      projectVerticesInto(verticesRef.current, rotation, projected)
      const morphingNow = morphingRef.current

      // Soften edges while morphing — depth opacity alone is enough for
      // layering; avoid reordering ~1k SVG nodes (that was the main hitch).
      const strokeWidth = morphingNow ? 1.25 : 1.35
      const opacityScale = morphingNow ? 0.82 : 1
      const strokeChanged =
        strokeWidth !== lastStrokeWidth || opacityScale !== lastOpacityScale
      lastStrokeWidth = strokeWidth
      lastOpacityScale = opacityScale

      for (let index = 0; index < EDGE_COUNT; index++) {
        const [start, end] = unifiedMesh.edges[index]!
        const a = projected[start]!
        const b = projected[end]!
        const el = lineRefs.current[index]
        if (!el) continue

        const depth = (a.z + b.z) / 2
        const opacity = (0.48 + ((depth + 120) / 240) * 0.42) * opacityScale

        el.setAttribute("x1", String(a.x))
        el.setAttribute("y1", String(a.y))
        el.setAttribute("x2", String(b.x))
        el.setAttribute("y2", String(b.y))
        el.setAttribute("stroke-opacity", String(opacity))
        if (strokeChanged) {
          el.setAttribute("stroke-width", String(strokeWidth))
        }
      }

      for (let index = 0; index < projected.length; index++) {
        const node = projected[index]!
        const depthNorm = (node.z + 110) / 220

        const bokeh = bokehRefs.current[index]
        if (bokeh) {
          bokeh.setAttribute("cx", String(node.x))
          bokeh.setAttribute("cy", String(node.y))
          bokeh.setAttribute("r", String(2.5 + depthNorm * 5))
          bokeh.setAttribute("fill-opacity", String(0.02 + depthNorm * 0.06))
        }

        const core = nodeRefs.current[index]
        if (core) {
          core.setAttribute("cx", String(node.x))
          core.setAttribute("cy", String(node.y))
          core.setAttribute("r", String(1.5 + depthNorm * 1.8))
          core.setAttribute("fill-opacity", String(0.78 + depthNorm * 0.22))
        }
      }
    }

    if (!active || reduceMotion) {
      render(reduceMotion ? 0.55 : 0)
      return
    }

    let lastTime = 0

    const tick = (time: number) => {
      if (!lastTime) lastTime = time
      // Clamp so a background-tab resume does not jump a huge rotation step.
      const delta = Math.min((time - lastTime) / 1000, 0.064)
      lastTime = time
      // Keep spin nearly constant during morph — a big speed jump reads as
      // a glitch when cards change quickly.
      const spinBoost = morphingRef.current ? 0.42 : 0.35
      rotation += delta * spinBoost
      render(rotation)
      raf = requestAnimationFrame(tick)
    }

    raf = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(raf)
  }, [active, reduceMotion, verticesRef, morphingRef])

  return (
    <g ref={groupRef} className="fsv10-geo__scene">
      {unifiedMesh.edges.map((_, index) => (
        <line
          key={`edge-${index}`}
          ref={(el) => {
            lineRefs.current[index] = el
          }}
          className="fsv10-geo__edge"
          stroke="currentColor"
          vectorEffect="non-scaling-stroke"
        />
      ))}

      {Array.from({ length: NODE_COUNT }, (_, index) => (
        <circle
          key={`bokeh-${index}`}
          ref={(el) => {
            bokehRefs.current[index] = el
          }}
          fill="currentColor"
          filter={`url(#${filterId}-blur)`}
        />
      ))}

      {Array.from({ length: NODE_COUNT }, (_, index) => (
        <circle
          key={`node-${index}`}
          ref={(el) => {
            nodeRefs.current[index] = el
          }}
          className="fsv10-geo__node"
          fill="currentColor"
          style={{ animationDelay: `${(index % 12) * 45}ms` }}
        />
      ))}
    </g>
  )
}

export default function GeoWireframe({ icon, active = false, className }: GeoWireframeProps) {
  const filterId = "fsv10-geo-morph"
  const svgRef = useRef<SVGSVGElement | null>(null)

  const onMorphingChange = (morphing: boolean) => {
    svgRef.current?.classList.toggle("fsv10-geo--morphing", morphing)
  }

  const { verticesRef, morphingRef } = useGeoMorph(icon, { onMorphingChange })

  const mounted = useSyncExternalStore(
    () => () => {},
    () => true,
    () => false
  )

  return (
    <div
      className={cn(
        "relative h-full min-h-full w-full overflow-hidden text-foreground",
        className
      )}
    >
      <svg
        ref={svgRef}
        aria-hidden
        viewBox={featureIllustrationViewBoxes[icon]}
        preserveAspectRatio="xMidYMid meet"
        className={cn("block h-full min-h-full w-full", active && "fsv10-geo--active")}
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        <defs>
          <filter id={`${filterId}-blur`} x="-50%" y="-50%" width="200%" height="200%">
            <feGaussianBlur stdDeviation="1.25" />
          </filter>
        </defs>

        {mounted && (
          <MeshScene
            active={active}
            morphingRef={morphingRef}
            filterId={filterId}
            verticesRef={verticesRef}
          />
        )}
      </svg>
    </div>
  )
}
```

### `registry/new-york/blocks/feature-section-v10/components/feature-illustrations.tsx`

```tsx
"use client"

import type { FeatureIconId } from "../lib/features"
import GeoWireframe from "./geo-wireframe"

type IllustrationProps = {
  active?: boolean
  className?: string
}

function createIllustration(icon: FeatureIconId) {
  return function FeatureGeoIllustration({ active, className }: IllustrationProps) {
    return <GeoWireframe icon={icon} active={active} className={className} />
  }
}

export const FlowIllustration = createIllustration("flow")
export const BranchIllustration = createIllustration("branch")
export const SignalIllustration = createIllustration("signal")
export const SecureIllustration = createIllustration("secure")

export type { IllustrationProps }
```

### `registry/new-york/blocks/feature-section-v10/hooks/use-geo-morph.ts`

```ts
"use client"

import { useEffect, useRef } from "react"
import { useReducedMotion } from "motion/react"
import gsap from "gsap"

import type { FeatureIconId } from "../lib/features"
import { getShapeVertices, lerpVec3Into, type Vec3 } from "../lib/geo-mesh"

/** Snappy enough to interrupt, long enough to read as a morph. */
const MORPH_DURATION = 0.65

function cloneVertices(vertices: readonly Vec3[]): Vec3[] {
  return vertices.map((vertex) => [vertex[0], vertex[1], vertex[2]] as Vec3)
}

function copyVerticesInto(target: Vec3[], source: readonly Vec3[]) {
  for (let i = 0; i < source.length; i++) {
    const src = source[i]!
    const dst = target[i]
    if (dst) {
      ;(dst as [number, number, number])[0] = src[0]
      ;(dst as [number, number, number])[1] = src[1]
      ;(dst as [number, number, number])[2] = src[2]
    } else {
      target[i] = [src[0], src[1], src[2]]
    }
  }
  target.length = source.length
}

type UseGeoMorphOptions = {
  /** Imperative signal — avoid React state so morph start/end does not reconcile the SVG tree. */
  onMorphingChange?: (morphing: boolean) => void
}

/**
 * Interpolates vertices between shapes without putting the per-frame tween
 * value in React state — `verticesRef` is mutated in place on every GSAP
 * tick so callers can read it inside their own rAF loop.
 *
 * Retargeting always starts from the *current* vertex positions (even mid-
 * morph), so rapid card switches never snap or get stuck mid-lerp.
 */
export function useGeoMorph(
  targetIcon: FeatureIconId,
  { onMorphingChange }: UseGeoMorphOptions = {}
) {
  const reduceMotion = useReducedMotion()
  const verticesRef = useRef<Vec3[]>(cloneVertices(getShapeVertices(targetIcon)))
  const morphingRef = useRef(false)
  const tweenRef = useRef<gsap.core.Tween | null>(null)
  /** Last shape that fully completed a morph. */
  const settledIconRef = useRef(targetIcon)
  /** Shape the active tween is heading toward (may differ from settled). */
  const aimIconRef = useRef(targetIcon)
  const onMorphingChangeRef = useRef(onMorphingChange)

  useEffect(() => {
    onMorphingChangeRef.current = onMorphingChange
  }, [onMorphingChange])

  const setMorphing = (next: boolean) => {
    if (morphingRef.current === next) return
    morphingRef.current = next
    onMorphingChangeRef.current?.(next)
  }

  useEffect(() => {
    const targetVertices = getShapeVertices(targetIcon)

    if (reduceMotion) {
      tweenRef.current?.kill()
      tweenRef.current = null
      copyVerticesInto(verticesRef.current, targetVertices)
      settledIconRef.current = targetIcon
      aimIconRef.current = targetIcon
      setMorphing(false)
      return
    }

    // Already tweening toward this target — do not restart.
    if (targetIcon === aimIconRef.current && tweenRef.current) return

    // Fully settled on this target.
    if (targetIcon === settledIconRef.current && !tweenRef.current) return

    // Interrupt in-flight morph and retarget from wherever vertices are now.
    tweenRef.current?.kill()
    aimIconRef.current = targetIcon

    const startVertices = cloneVertices(verticesRef.current)
    const proxy = { t: 0 }
    setMorphing(true)

    tweenRef.current = gsap.to(proxy, {
      t: 1,
      duration: MORPH_DURATION,
      ease: "power2.inOut",
      onUpdate: () => {
        const current = verticesRef.current
        for (let i = 0; i < current.length; i++) {
          lerpVec3Into(current[i]!, startVertices[i]!, targetVertices[i]!, proxy.t)
        }
      },
      onComplete: () => {
        copyVerticesInto(verticesRef.current, targetVertices)
        settledIconRef.current = targetIcon
        tweenRef.current = null
        setMorphing(false)
      },
    })
  }, [targetIcon, reduceMotion])

  useEffect(() => {
    return () => {
      tweenRef.current?.kill()
      tweenRef.current = null
    }
  }, [])

  return { verticesRef, morphingRef }
}

export { MORPH_DURATION }
```

### `registry/new-york/blocks/feature-section-v10/hooks/use-hover-intent.ts`

```ts
"use client"

import { useEffect, useRef } from "react"

/**
 * Delays hover-driven activation so sweeping across cards does not thrash
 * the morph. Focus / click should call the returned activate immediately
 * (pass `immediate: true`) for keyboard a11y.
 */
export function useHoverIntent<T>(
  onCommit: (value: T) => void,
  delayMs = 110
) {
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  const onCommitRef = useRef(onCommit)

  useEffect(() => {
    onCommitRef.current = onCommit
  }, [onCommit])

  useEffect(() => {
    return () => {
      if (timeoutRef.current !== null) clearTimeout(timeoutRef.current)
    }
  }, [])

  const schedule = (value: T) => {
    if (timeoutRef.current !== null) clearTimeout(timeoutRef.current)
    timeoutRef.current = setTimeout(() => {
      timeoutRef.current = null
      onCommitRef.current(value)
    }, delayMs)
  }

  const commit = (value: T) => {
    if (timeoutRef.current !== null) clearTimeout(timeoutRef.current)
    timeoutRef.current = null
    onCommitRef.current(value)
  }

  const cancel = () => {
    if (timeoutRef.current !== null) clearTimeout(timeoutRef.current)
    timeoutRef.current = null
  }

  return { schedule, commit, cancel }
}
```

### `registry/new-york/blocks/feature-section-v10/lib/geo-mesh.ts`

```ts
import type { FeatureIconId } from "./features"

export type Vec3 = readonly [number, number, number]

export type GeoMesh = {
  vertices: readonly Vec3[]
  edges: readonly (readonly [number, number])[]
}

export type ProjectedPoint = {
  x: number
  y: number
  z: number
  index: number
}

const VIEW_CX = 240
const VIEW_CY = 180
const VIEW_SCALE = 1.48

/** Shared lattice — identical topology lets vertices morph between shapes. */
export const MESH_RINGS = 15
export const MESH_SEGMENTS = 18

export function projectVertices(
  vertices: readonly Vec3[],
  rotateY = 0
): ProjectedPoint[] {
  const cos = Math.cos(rotateY)
  const sin = Math.sin(rotateY)

  return vertices.map((vertex, index) => {
    const [x, y, z] = vertex
    const rx = x * cos - z * sin
    const rz = x * sin + z * cos
    const depth = 1 + rz * 0.0028
    const tilt = y * 0.08

    return {
      x: VIEW_CX + (rx / depth) * VIEW_SCALE,
      y: VIEW_CY + ((y + tilt) / depth) * VIEW_SCALE,
      z: rz,
      index,
    }
  })
}

/** Same projection as {@link projectVertices}, writing into `out` in place to avoid per-frame allocation. */
export function projectVerticesInto(
  vertices: readonly Vec3[],
  rotateY: number,
  out: ProjectedPoint[]
): ProjectedPoint[] {
  const cos = Math.cos(rotateY)
  const sin = Math.sin(rotateY)

  for (let index = 0; index < vertices.length; index++) {
    const [x, y, z] = vertices[index]!
    const rx = x * cos - z * sin
    const rz = x * sin + z * cos
    const depth = 1 + rz * 0.0028
    const tilt = y * 0.08

    const point = out[index]
    if (point) {
      point.x = VIEW_CX + (rx / depth) * VIEW_SCALE
      point.y = VIEW_CY + ((y + tilt) / depth) * VIEW_SCALE
      point.z = rz
      point.index = index
    } else {
      out[index] = {
        x: VIEW_CX + (rx / depth) * VIEW_SCALE,
        y: VIEW_CY + ((y + tilt) / depth) * VIEW_SCALE,
        z: rz,
        index,
      }
    }
  }

  out.length = vertices.length
  return out
}

function gridEdges(ringCount: number, segmentCount: number): (readonly [number, number])[] {
  const edges: (readonly [number, number])[] = []
  const indexAt = (ring: number, segment: number) =>
    ring * segmentCount + (((segment % segmentCount) + segmentCount) % segmentCount)

  for (let ring = 0; ring < ringCount; ring++) {
    for (let segment = 0; segment < segmentCount; segment++) {
      edges.push([indexAt(ring, segment), indexAt(ring, segment + 1)])
      edges.push([indexAt(ring, segment), indexAt(ring + 1, segment)])
    }
  }

  return edges
}

type ShapeSample = {
  ring: number
  segment: number
  tRing: number
  tSeg: number
  angle: number
}

function sampleGrid(ring: number, segment: number): ShapeSample {
  return {
    ring,
    segment,
    tRing: ring / MESH_RINGS,
    tSeg: segment / MESH_SEGMENTS,
    angle: (segment / MESH_SEGMENTS) * Math.PI * 2,
  }
}

/** Wireframe sphere — security / trust boundary. */
function sphereVertex({ tRing, angle }: ShapeSample): Vec3 {
  const phi = tRing * Math.PI
  const radius = 88
  const y = Math.cos(phi) * radius
  const ringRadius = Math.sin(phi) * radius

  return [
    Math.cos(angle) * ringRadius,
    y,
    Math.sin(angle) * ringRadius,
  ]
}

/**
 * Soft cube (superellipsoid) — one shared workspace / source of truth.
 * Same spherical parameterization as the secure sphere, then L^p-normalized
 * toward a cube so morphs stay continuous.
 */
function cubeVertex({ tRing, angle }: ShapeSample): Vec3 {
  const phi = tRing * Math.PI
  const sx = Math.sin(phi) * Math.cos(angle)
  const sy = Math.cos(phi)
  const sz = Math.sin(phi) * Math.sin(angle)
  const p = 5
  const norm = Math.pow(
    Math.abs(sx) ** p + Math.abs(sy) ** p + Math.abs(sz) ** p,
    1 / p
  )
  const radius = 88
  const scale = norm > 0 ? radius / norm : radius

  return [sx * scale, sy * scale, sz * scale]
}

/**
 * Dual helix — two parallel lanes that wind together without colliding.
 * A peanut cross-section twists up the Y axis so both strands stay one
 * continuous lattice (no hard strand picks that break morphs).
 */
function helixVertex({ tRing, angle }: ShapeSample): Vec3 {
  const turns = 2.1
  const twist = tRing * Math.PI * 2 * turns
  const height = (tRing - 0.5) * 172

  const sharpness = 3.2
  const lobe = Math.tanh(sharpness * Math.cos(angle))
  const separation = 30
  const baseRadius = 15
  const pinch = 0.58
  const localRadius = baseRadius * (1 - pinch * (1 - lobe * lobe))

  const localX = lobe * separation + Math.cos(angle) * localRadius
  const localZ = Math.sin(angle) * localRadius * 0.72

  const cos = Math.cos(twist)
  const sin = Math.sin(twist)

  return [
    localX * cos - localZ * sin,
    height,
    localX * sin + localZ * cos,
  ]
}

/** Torus lattice — signals / notifications. */
function torusVertex({ tRing, angle }: ShapeSample): Vec3 {
  const majorRadius = 72
  const minorRadius = 26
  const majorAngle = tRing * Math.PI * 2
  const cx = Math.cos(majorAngle) * majorRadius
  const cz = Math.sin(majorAngle) * majorRadius
  const nx = Math.cos(majorAngle) * Math.cos(angle)
  const ny = Math.sin(angle)
  const nz = Math.sin(majorAngle) * Math.cos(angle)

  return [
    cx + nx * minorRadius,
    ny * minorRadius,
    cz + nz * minorRadius,
  ]
}

const shapeBuilders: Record<FeatureIconId, (sample: ShapeSample) => Vec3> = {
  flow: cubeVertex,
  branch: helixVertex,
  signal: torusVertex,
  secure: sphereVertex,
}

function buildShapeVertices(icon: FeatureIconId): Vec3[] {
  const build = shapeBuilders[icon]
  const vertices: Vec3[] = []

  for (let ring = 0; ring <= MESH_RINGS; ring++) {
    for (let segment = 0; segment < MESH_SEGMENTS; segment++) {
      vertices.push(build(sampleGrid(ring, segment)))
    }
  }

  return vertices
}

function centerVertices3D(vertices: readonly Vec3[]): Vec3[] {
  let ox = 0
  let oy = 0
  let oz = 0

  for (const [x, y, z] of vertices) {
    ox += x
    oy += y
    oz += z
  }

  const count = vertices.length
  ox /= count
  oy /= count
  oz /= count

  return vertices.map(([x, y, z]) => [x - ox, y - oy, z - oz] as const)
}

function getProjectedMaxDim(vertices: readonly Vec3[]): number {
  const projected = projectVertices(vertices, 0)
  let minX = Number.POSITIVE_INFINITY
  let maxX = Number.NEGATIVE_INFINITY
  let minY = Number.POSITIVE_INFINITY
  let maxY = Number.NEGATIVE_INFINITY

  for (const point of projected) {
    minX = Math.min(minX, point.x)
    maxX = Math.max(maxX, point.x)
    minY = Math.min(minY, point.y)
    maxY = Math.max(maxY, point.y)
  }

  return Math.max(maxX - minX, maxY - minY)
}

/** Scale every shape to match the secure sphere footprint in the panel. */
function matchShapeToReference(vertices: readonly Vec3[], referenceMaxDim: number): Vec3[] {
  const centered = centerVertices3D(vertices)
  const maxDim = getProjectedMaxDim(centered)
  const scale = maxDim > 0 ? referenceMaxDim / maxDim : 1

  return centered.map(([x, y, z]) => [x * scale, y * scale, z * scale] as const)
}

function buildNormalizedShapeCache(): Record<FeatureIconId, readonly Vec3[]> {
  const referenceVertices = centerVertices3D(buildShapeVertices("secure"))
  const referenceMaxDim = getProjectedMaxDim(referenceVertices)

  return {
    flow: matchShapeToReference(buildShapeVertices("flow"), referenceMaxDim),
    branch: matchShapeToReference(buildShapeVertices("branch"), referenceMaxDim),
    signal: matchShapeToReference(buildShapeVertices("signal"), referenceMaxDim),
    secure: referenceVertices,
  }
}

const normalizedShapeCache = buildNormalizedShapeCache()

export function getShapeVertices(icon: FeatureIconId): readonly Vec3[] {
  return normalizedShapeCache[icon]
}

export function lerpVec3(a: Vec3, b: Vec3, t: number): Vec3 {
  return [
    a[0] + (b[0] - a[0]) * t,
    a[1] + (b[1] - a[1]) * t,
    a[2] + (b[2] - a[2]) * t,
  ]
}

/** In-place lerp — avoids allocating a new tuple every GSAP tick. */
export function lerpVec3Into(out: Vec3, a: Vec3, b: Vec3, t: number): void {
  ;(out as [number, number, number])[0] = a[0] + (b[0] - a[0]) * t
  ;(out as [number, number, number])[1] = a[1] + (b[1] - a[1]) * t
  ;(out as [number, number, number])[2] = a[2] + (b[2] - a[2]) * t
}

const unifiedEdges = gridEdges(MESH_RINGS, MESH_SEGMENTS)

export const unifiedMesh: GeoMesh = {
  vertices: normalizedShapeCache.secure,
  edges: unifiedEdges,
}

/** @deprecated Use getShapeVertices + unifiedMesh.edges */
export const geoMeshes = {
  flow: { vertices: normalizedShapeCache.flow, edges: unifiedEdges },
  branch: { vertices: normalizedShapeCache.branch, edges: unifiedEdges },
  signal: { vertices: normalizedShapeCache.signal, edges: unifiedEdges },
  secure: { vertices: normalizedShapeCache.secure, edges: unifiedEdges },
} as const
```

### `registry/new-york/blocks/feature-section-v10/styles/feature-section-v10.css`

```css
@keyframes fsv10-geo-node-pulse {
  0%,
  100% {
    opacity: 0.8;
    transform: scale(1);
  }
  50% {
    opacity: 0.95;
    transform: scale(1.15);
  }
}

.fsv10-geo__node {
  transform-box: fill-box;
  transform-origin: center;
}

.fsv10-geo--active .fsv10-geo__node {
  animation: fsv10-geo-node-pulse 2.8s ease-in-out infinite;
}

/* Pause node pulse while morphing — CSS opacity fights the JS depth pass. */
.fsv10-geo--morphing .fsv10-geo__node {
  animation: none;
}

@media (prefers-reduced-motion: reduce) {
  .fsv10-geo__node {
    animation: none;
    transform: none;
  }
}
```

### `registry/new-york/blocks/feature-section-v10/lib/features.ts`

```ts
export type FeatureIconId = "flow" | "branch" | "signal" | "secure"

export const featureSectionTitle = "Get work done without the clutter"

export const featureIllustrationViewBoxes: Record<FeatureIconId, string> = {
  flow: "0 0 480 360",
  branch: "0 0 480 360",
  signal: "0 0 480 360",
  secure: "0 0 480 360",
}

export type FeatureItem = {
  id: string
  title: string
  description: string
  icon: FeatureIconId
}

export const featureSectionItems: FeatureItem[] = [
  {
    id: "one-source-of-truth",
    title: "Everything in one place",
    description:
      "Bring related work into a single shared space so you stop hunting across tabs and tools.",
    icon: "flow",
  },
  {
    id: "work-in-parallel",
    title: "Try more than one path",
    description:
      "Explore options side by side, then keep what works and leave the rest behind.",
    icon: "branch",
  },
  {
    id: "stay-in-the-loop",
    title: "Only the updates that matter",
    description:
      "Hear from the product when something needs you, and stay quiet when nothing does.",
    icon: "signal",
  },
  {
    id: "secure-by-default",
    title: "Clear control over access",
    description:
      "Decide who can see and change what, with permissions that are easy to set and easy to explain.",
    icon: "secure",
  },
]
```
