Skip to content

Section

A block of text with one accessory beside it — a thumbnail, or a button.

A block of text with one accessory beside it — a thumbnail, or a button. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

import {
  Section,
  SectionAccessory,
  SectionContent,
  SectionHeading,
  SectionThumbnail,
} from "@/registry/bases/newt/ui/section"

/*
 * Drawn as a data URI rather than fetched: a demo that needs the network is a
 * demo that fails offline, and the thumbnail only has to be a thumbnail.
 */
const COVER =
  "data:image/svg+xml;utf8," +
  encodeURIComponent(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 86 86">
      <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
        <stop offset="0" stop-color="#5865f2"/><stop offset="1" stop-color="#eb459e"/>
      </linearGradient></defs>
      <rect width="86" height="86" fill="url(#g)"/>
    </svg>`
  )

export default function SectionDemo() {
  return (
    <Section className="max-w-[520px]">
      <SectionContent>
        <SectionHeading>Release 7.3</SectionHeading>
        <p>
          The update is out. Chests open on the first try, the servers hold at
          peak hours, and gravity only changes on Thursdays.
        </p>
      </SectionContent>
      <SectionAccessory>
        <SectionThumbnail src={COVER} alt="Release artwork" />
      </SectionAccessory>
    </Section>
  )
}
<script setup lang="ts">
import {
  Section,
  SectionAccessory,
  SectionContent,
  SectionHeading,
  SectionThumbnail,
} from "~~/registry/bases/newt/ui/section"

/*
 * Drawn as a data URI rather than fetched: a demo that needs the network is a
 * demo that fails offline, and the thumbnail only has to be a thumbnail.
 */
const cover =
  "data:image/svg+xml;utf8," +
  encodeURIComponent(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 86 86">
      <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
        <stop offset="0" stop-color="#5865f2"/><stop offset="1" stop-color="#eb459e"/>
      </linearGradient></defs>
      <rect width="86" height="86" fill="url(#g)"/>
    </svg>`
  )
</script>

<template>
  <Section class="max-w-[520px]">
    <SectionContent>
      <SectionHeading>Release 7.3</SectionHeading>
      <p>
        The update is out. Chests open on the first try, the servers hold at
        peak hours, and gravity only changes on Thursdays.
      </p>
    </SectionContent>
    <SectionAccessory>
      <SectionThumbnail :src="cover" alt="Release artwork" />
    </SectionAccessory>
  </Section>
</template>

Installation

pnpm dlx newtui@latest add section
npx newtui@latest add section
yarn dlx newtui@latest add section
bunx newtui@latest add section

Copy the source into your project

import * as React from "react"

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

/*
 * A block of text with one thing beside it: a thumbnail, or a button. That
 * pairing is the whole component — the text column takes the space that is
 * left, and the accessory keeps its own width whatever the text does.
 */
const Section = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("flex w-full items-start gap-4", className)}
    {...props}
  />
))
Section.displayName = "Section"

const SectionContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn(
      "flex min-w-0 flex-1 flex-col gap-2 text-[15px] leading-[1.375] text-newt-text-secondary",
      className
    )}
    {...props}
  />
))
SectionContent.displayName = "SectionContent"

const SectionHeading = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
  <p
    ref={ref}
    className={cn(
      "font-display text-xl font-bold leading-tight text-newt-text-primary",
      className
    )}
    {...props}
  />
))
SectionHeading.displayName = "SectionHeading"

/*
 * The accessory never shrinks and never stretches: it is the fixed side of
 * the pair, which is what keeps a long paragraph from squeezing a thumbnail
 * into a sliver.
 */
const SectionAccessory = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("flex shrink-0 items-start", className)}
    {...props}
  />
))
SectionAccessory.displayName = "SectionAccessory"

export interface SectionThumbnailProps extends React.ImgHTMLAttributes<HTMLImageElement> {
  /**
   * Marks the image as a spoiler: blurred until clicked, the same treatment
   * a spoilered attachment gets.
   */
  spoiler?: boolean
}

const SectionThumbnail = React.forwardRef<
  HTMLImageElement,
  SectionThumbnailProps
>(({ className, spoiler = false, alt = "", ...props }, ref) => {
  const [revealed, setRevealed] = React.useState(false)
  const hidden = spoiler && !revealed

  return (
    <img
      ref={ref}
      alt={alt}
      data-spoiler={hidden || undefined}
      /*
       * A spoilered thumbnail is a button until it is opened: it takes a
       * click and a key, and says what it is to a screen reader.
       */
      role={hidden ? "button" : undefined}
      tabIndex={hidden ? 0 : undefined}
      aria-label={hidden ? "Spoiler, click to reveal" : undefined}
      onClick={() => setRevealed(true)}
      onKeyDown={(event) => {
        if (!hidden) return
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault()
          setRevealed(true)
        }
      }}
      className={cn(
        "h-[86px] w-[86px] rounded-lg object-cover",
        hidden && "cursor-pointer blur-lg",
        className
      )}
      {...props}
    />
  )
})
SectionThumbnail.displayName = "SectionThumbnail"

export {
  Section,
  SectionAccessory,
  SectionContent,
  SectionHeading,
  SectionThumbnail,
}
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

/*
 * A block of text with one thing beside it: a thumbnail, or a button. That
 * pairing is the whole component — the text column takes the space that is
 * left, and the accessory keeps its own width whatever the text does.
 */
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div :class="cn('flex w-full items-start gap-4', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

/*
 * The accessory never shrinks and never stretches: it is the fixed side of
 * the pair, which is what keeps a long paragraph from squeezing a thumbnail
 * into a sliver.
 */
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div :class="cn('flex shrink-0 items-start', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div
    :class="
      cn(
        'flex min-w-0 flex-1 flex-col gap-2 text-[15px] leading-[1.375] text-newt-text-secondary',
        props.class
      )
    "
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <p
    :class="
      cn(
        'font-display text-xl font-bold leading-tight text-newt-text-primary',
        props.class
      )
    "
  >
    <slot />
  </p>
</template>
<script setup lang="ts">
import { ref, type HTMLAttributes } from "vue"

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

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    src: string
    alt?: string
    /**
     * Marks the image as a spoiler: blurred until clicked, the same treatment
     * a spoilered attachment gets.
     */
    spoiler?: boolean
  }>(),
  { alt: "", spoiler: false }
)

const revealed = ref(false)

function reveal() {
  revealed.value = true
}

function onKeydown(event: KeyboardEvent) {
  if (revealed.value || !props.spoiler) return
  if (event.key === "Enter" || event.key === " ") {
    event.preventDefault()
    reveal()
  }
}
</script>

<template>
  <img
    :src="props.src"
    :alt="props.alt"
    :data-spoiler="props.spoiler && !revealed ? true : undefined"
    :role="props.spoiler && !revealed ? 'button' : undefined"
    :tabindex="props.spoiler && !revealed ? 0 : undefined"
    :aria-label="
      props.spoiler && !revealed ? 'Spoiler, click to reveal' : undefined
    "
    :class="
      cn(
        'h-[86px] w-[86px] rounded-lg object-cover',
        props.spoiler && !revealed && 'cursor-pointer blur-lg',
        props.class
      )
    "
    @click="reveal"
    @keydown="onKeydown"
  />
</template>
export { default as Section } from "./Section.vue"
export { default as SectionAccessory } from "./SectionAccessory.vue"
export { default as SectionContent } from "./SectionContent.vue"
export { default as SectionHeading } from "./SectionHeading.vue"
export { default as SectionThumbnail } from "./SectionThumbnail.vue"

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { Section } from "@/components/ui/section"

export function Example() {
  return <Section />
}
<script setup lang="ts">
import { Section, SectionAccessory, SectionContent, SectionHeading, SectionThumbnail } from "@/components/ui/section"
</script>

<template>
  <Section />
</template>

API reference

Section

PropType
children / default slot?React.ReactNode / Vue slot

Content rendered inside the component.

TypeReact.ReactNode / Vue slot
className / class?string

Additional classes merged with the component defaults.

Typestring

Accessibility

Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.

Tokens

Section reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.