Skip to content

Spoiler

Inline text hidden behind a dark block until the reader clicks to reveal it.

Inline text hidden behind a dark block until the reader clicks to reveal it. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

Root class newt-spoiler
import { Spoiler } from "@/registry/bases/newt/ui/spoiler"

export default function SpoilerDemo() {
  return (
    <p className="text-sm text-newt-text-primary">
      The ending was <Spoiler>hidden text</Spoiler> all along.
    </p>
  )
}
<script setup lang="ts">
import { Spoiler } from "~~/registry/bases/newt/ui/spoiler"
</script>

<template>
  <p class="text-sm text-newt-text-primary">
    The ending was <Spoiler>hidden text</Spoiler> all along.
  </p>
</template>

Installation

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

Copy the source into your project

"use client"

import * as React from "react"

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

export interface SpoilerProps extends React.HTMLAttributes<HTMLSpanElement> {
  /** Controlled revealed state. */
  revealed?: boolean
  /** Initial revealed state when uncontrolled. */
  defaultRevealed?: boolean
  /** Called with the next revealed state when toggled. */
  onRevealedChange?: (revealed: boolean) => void
}

const Spoiler = React.forwardRef<HTMLSpanElement, SpoilerProps>(
  (
    {
      className,
      revealed: revealedProp,
      defaultRevealed = false,
      onRevealedChange,
      onClick,
      onKeyDown,
      children,
      ...props
    },
    ref
  ) => {
    const [uncontrolled, setUncontrolled] = React.useState(defaultRevealed)
    const isControlled = revealedProp !== undefined
    const revealed = isControlled ? revealedProp : uncontrolled

    const toggle = () => {
      const next = !revealed
      if (!isControlled) setUncontrolled(next)
      onRevealedChange?.(next)
    }

    const handleClick = (event: React.MouseEvent<HTMLSpanElement>) => {
      onClick?.(event)
      if (event.defaultPrevented) return
      toggle()
    }

    const handleKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
      onKeyDown?.(event)
      if (event.defaultPrevented || revealed) return
      if (event.key === "Enter" || event.key === " ") {
        event.preventDefault()
        toggle()
      }
    }

    return (
      <span
        ref={ref}
        role={revealed ? undefined : "button"}
        tabIndex={revealed ? undefined : 0}
        aria-label={revealed ? undefined : "Spoiler, click to reveal"}
        aria-expanded={revealed}
        data-state={revealed ? "revealed" : "hidden"}
        className={cn(
          "rounded-sm px-1 transition-[background-color] duration-fast ease-newt",
          revealed
            ? "cursor-text select-text bg-newt-bg-active text-newt-text-primary"
            : "cursor-pointer select-none bg-[#1a1a1e] text-transparent hover:bg-[#232328]",
          className
        )}
        onClick={handleClick}
        onKeyDown={handleKeyDown}
        {...props}
      >
        {children}
      </span>
    )
  }
)
Spoiler.displayName = "Spoiler"

export { Spoiler }
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, ref, watch } from "vue"

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

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    /** Controlled revealed state (use with v-model:revealed). */
    revealed?: boolean
    /** Initial revealed state when uncontrolled. */
    defaultRevealed?: boolean
  }>(),
  { revealed: undefined, defaultRevealed: false }
)

const emit = defineEmits<{ (e: "update:revealed", value: boolean): void }>()

const internal = ref(props.revealed ?? props.defaultRevealed)
watch(
  () => props.revealed,
  (v) => {
    if (v !== undefined) internal.value = v
  }
)
const isRevealed = computed(() =>
  props.revealed !== undefined ? props.revealed : internal.value
)

function toggle() {
  const next = !isRevealed.value
  if (props.revealed === undefined) internal.value = next
  emit("update:revealed", next)
}

function onKeydown(event: KeyboardEvent) {
  if (isRevealed.value) return
  if (event.key === "Enter" || event.key === " ") {
    event.preventDefault()
    toggle()
  }
}
</script>

<template>
  <span
    :role="isRevealed ? undefined : 'button'"
    :tabindex="isRevealed ? undefined : 0"
    :aria-label="isRevealed ? undefined : 'Spoiler, click to reveal'"
    :aria-expanded="isRevealed"
    :data-state="isRevealed ? 'revealed' : 'hidden'"
    :class="
      cn(
        'rounded-sm px-1 transition-[background-color] duration-fast ease-newt',
        isRevealed
          ? 'cursor-text select-text bg-newt-bg-active text-newt-text-primary'
          : 'cursor-pointer select-none bg-[#1a1a1e] text-transparent hover:bg-[#232328]',
        props.class
      )
    "
    @click="toggle"
    @keydown="onKeydown"
  >
    <slot />
  </span>
</template>
export { default as Spoiler } from "./Spoiler.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 { Spoiler } from "@/components/ui/spoiler"

export function Example() {
  return <Spoiler />
}
<script setup lang="ts">
import { Spoiler } from "@/components/ui/spoiler"
</script>

<template>
  <Spoiler />
</template>

API reference

Spoiler

PropType
revealed?boolean | undefined

Controlled revealed state.

Typeboolean | undefined
defaultRevealed?boolean | undefined

Initial revealed state when uncontrolled.

Typeboolean | undefined
onRevealedChange?(revealed: boolean) => void | undefined

Called with the next revealed state when toggled.

Type(revealed: boolean) => void | undefined

The registry exports Spoiler. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.

Accessibility

The reveal control must be keyboard operable and expose its expanded state. Do not place essential instructions only inside hidden spoiler content.

Tokens

Spoiler 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. In the plain HTML and CSS distribution the same component is newt-spoiler.