Henrique Doro
Sanity user & community member turned employee ๐ (Applications Engineer)
Render Portable Text block content with Svelte components.
Render Portable Text block content with Svelte components.
npm i @portabletext/svelte -D
<script> import PortableText from '@portabletext/svelte' </script> <PortableText blocks={[ // Portable Text array ... ]} />
This is enough to get you set-up with basic block content with formatting and text styles. When working with images, custom styles, blocks & marks, though, you'll need to customize your renderer with serializers:
You can use the serializers
prop to determine how the renderer should process each block, mark or style type.
<PortableText blocks={[ // Portable Text array ... ]} serializers={{ types: { // block-level components callout: Callout, // inline-level components userInfo: UserInfo }, marks: { absUrl: AbsoluteURL, // Overwrite default mark renderers strong: CustomStrong }, blockStyles: { normal: CustomParagraph, blockquote: Quote, // Re-using the same component across multiple styles h1: CustomHeading, h2: CustomHeading, h3: CustomHeading, // Swap only the list parts you need list_bullet: UnorderedListWrapper, list_number: OrderedListWrapper, listItem_bullet: ListItem, listItem_number: ListItem, // Custom user-defined style textCenter: CentralizedText } }} />
Example components from above:
<!-- UserInfo (block type) --> <script lang="ts"> import {session} from '$app/stores' import type {BlockProps} from '@portabletext/svelte' // Property custom blocks receive from @portabletext/svelte when redered export let portableText: BlockProps<{bold?: boolean}> $: userName = $session?.user?.name || 'person' </script> {#if portableText.block.bold} <strong>{userName}</strong> {:else} {userName} {/if}
<!-- AbsoluteURL (custom mark) --> <script lang="ts"> import type {MarkProps} from '@portabletext/svelte' // Property custom marks receive from @portabletext/svelte when redered export let portableText: MarkProps<{ url?: string newWindow?: boolean }> // Remember to make your variables reactive so that they can reflect prop changes // See: https://svelte.dev/docs#3_$_marks_a_statement_as_reactive $: mark = portableText.mark $: newWindow = mark.newWindow || false </script> {#if mark.url} <a href={mark.url} target={newWindow ? '_blank' : undefined}><slot /></a> {:else} <slot /> {/if}
๐ To keep in mind: Svelte's SSR mode seems to have issues with whitespace (see #3168), where it does strip unnecessary space between components. Due to this, marks (formatting, links, etc.) some times are rendered incorrectly.
<!-- CustomHeading (blockStyle) --> <script lang="ts"> import type {BlockProps} from '@portabletext/svelte' export let portableText: BlockProps $: index = portableText.index $: blocks = portableText.blocks $: block = portableText.block $: style = block.style $: precededByHeading = ['h1', 'h2', 'h3', 'h4', 'h5'].includes(blocks[index - 1]?.style) $: anchorId = `heading-${block._key}` </script> <!-- If preceded by heading, have a higher margin top --> <div class="relative {precededByHeading ? 'mt-10' : 'mt-4'}" id={anchorId}> <a href="#{anchorId}"> <span class="sr-only">Link to this heading</span> ๐ </a> {#if style === 'h1'} <h1 class="text-4xl font-black"><slot /></h1> {:else if style === 'h2'} <h2 class="text-3xl"><slot /></h2> {:else if style === 'h3'} <h3 class="text-xl"><slot /></h3> {:else} <h4 class="text-lg text-gray-600"><slot /></h4> {/if} </div>
The component above is also an example of how you can access blocks surrounding the current one for rule-based design.
Finally, you can pass context
to your <PortableText>
component to have custom external data exposed to all components. This is useful in scenarios such as:
blocks
only onceHere's a complete example with a footnote
annotation, where editors focus on writing its contents, and the front-end smartly position it and define its number:
<!-- Our page's content --> <script> import Footnote from './Foonote.svelte' export let blocks // Get all footnotes from markDefs in top-level blocks $: footnotes = blocks.reduce((notes, curBlock) => { if (curBlock._type !== 'block' || !curBlock.markDefs?.length) { return notes } return [...notes, ...curBlock.markDefs.filter((def) => def._type === 'footnote')] }, []) </script> <PortableText {blocks} serializers={{ marks: { footnote: Footnote } }} context={{ // Pass these footnotes inside the context footnotes }} /> <!-- And render them at the bottom --> <ol> {#each footnotes as note} <li id="note-{note._key}"> <PortableText blocks={note.note} serializers={{ marks: { link: Link } }} /> <a href="#src-{note._key}">๐</a> </li> {/each} </ol> <!-- Footnote.svelte --> <script lang="ts"> import type {MarkProps} from '@portabletext/svelte' interface FootnoteProps { _key: string note: PortableTextBlocks } export let portableText: MarkProps< FootnoteProps, // Use the second argument to specify your context's type { footnotes: FootnoteProps[] } > // From the context, let's figure out what's the position of this footnote $: number = portableText.context.footnotes.findIndex((note) => note._key === portableText.mark._key) + 1 </script> <span id="src-{portableText.mark._key}"> <slot /><sup><a href={`#note-${portableText.mark._key}`}>{number}</a></sup> </span>
Big thanks to Emma Agering, Jacob Stรธrdahl, Ollie Taylor, Rune, Stephane Vanraes & Toby Milner-Gulland (alphabetical order) for working on their custom renderers while we didn't have an official one. You've helped many ship Svelte + Sanity projects!
MIT-licensed. See LICENSE.
npm i @portabletext/svelte -D
Sanity user & community member turned employee ๐ (Applications Engineer)
Allows uploading, referencing and deleting video and audio files to S3 directly from your Sanity studio.
Go to AWS S3 media browserEditor friendly slug fields for your Sanity.io studio
Go to Better slug inputShow your editors how their page will look on Google and major social platforms in your documents' view.
Go to Sanity social and SEO document previewAllow editors to generate images on the fly inside of Sanity ๐ฅ
Go to OG Image generator asset source