Translate the UI text
Replace every string the comment components render with the labels on the provider and on each component.
Every string the default components render can be replaced, except one, so a comments surface in another language is configured rather than forked. This guide covers the labels object the provider takes, the labels each default component takes on top of it, which props carry copy on the primitives, and the one string still out of reach.
Prerequisites:
- A comments surface under
CommentsProvider, or underSDKCommentsProviderrendered insideSanityAppwith aSuspenseboundary and an error boundary around it. See Get started with comments.
Translate the whole surface
Pass labels to CommentsProvider, or to SDKCommentsProvider, which hands it on. It reaches every component below: the panel, the thread cards, the comments with their menus and dialogs, every composer including the inline one, the list, and the reactions.
import {SDKCommentsProvider, SDKCommentsPanel} from '@sanity/collaboration/sdk'
import type {CommentsLabels} from '@sanity/collaboration/ui'
import {Suspense} from 'react'
const REACTION_NAMES: Record<string, string> = {
':+1:': 'Tommel opp',
':-1:': 'Tommel ned',
':eyes:': 'Øyne',
':heart:': 'Hjerte',
':heavy_plus_sign:': 'Pluss',
':rocket:': 'Rakett',
}
const norwegian: CommentsLabels = {
panel: {
panel: 'Kommentarer',
open: 'Åpne',
resolved: 'Løste',
addThread: 'Start en ny tråd',
composerPlaceholder: (target) =>
target.fieldPath === ''
? 'Kommenter dokumentet…'
: `Kommenter ${target.fieldPath}…`,
empty: (status) =>
status === 'open'
? 'Ingen åpne kommentarer.'
: 'Ingen løste kommentarer.',
},
thread: {
showMoreReplies: (count) => `Vis ${count} til`,
showFewerReplies: 'Skjul',
replyPlaceholder: 'Svar eller nevn med @',
},
comment: {
actions: {
resolve: 'Løs kommentaren',
reopen: 'Gjenåpne kommentaren',
edit: 'Rediger',
delete: 'Slett',
},
deleteTitle: 'Slette kommentaren?',
deleteConfirm: 'Slett',
deleteCancel: 'Avbryt',
unknownAuthor: 'Ukjent',
edited: '(redigert)',
},
input: {
placeholder: 'Skriv en kommentar…',
submit: 'Kommenter',
cancel: 'Avbryt',
mentionsEmpty: 'Ingen brukere funnet',
},
list: {loading: 'Laster kommentarer…'},
inline: {button: 'Kommenter'},
reactions: {name: (shortName) => REACTION_NAMES[shortName] ?? shortName},
}
export function Comments() {
return (
<Suspense fallback={<p>Resolving your identity…</p>}>
<SDKCommentsProvider labels={norwegian}>
<Suspense fallback={<p>Laster kommentarer…</p>}>
<SDKCommentsPanel
documentId="post-123"
documentType="post"
compose={{fieldPath: 'title'}}
/>
</Suspense>
</SDKCommentsProvider>
</Suspense>
)
}This is the Comments component from Get started with labels added. SDKCommentsProvider suspends while it resolves who you are, so it stays under the same Suspense boundary. Every section, and every entry in it, is optional. What you leave out keeps the English default, so a single string can be replaced without restating the rest. Define the object once, outside render, as the example does. Each section reaches these components:
Section | Reaches |
|---|---|
|
|
|
|
| Every |
| Every composer: reply, edit, the panel's, and the inline one |
|
|
|
|
| Reaction names, in the accessible names of pills and picker |
A count or a field path is a function rather than a template, since a language that pluralizes differently from English cannot be served by substitution alone. So is a reaction name: shortnames are an open set, so return the shortname itself for one you do not know.
Some of these strings are never drawn: thread.thread names a thread card for a screen reader, and panel.statusFilter names the panel's status tabs. They are worth translating even though no sighted reader sees them. panel.panel is both the panel's accessible name and the title in its header.
Override one component
Comment, CommentInput, CommentThread, and CommentsPanel each take a labels prop of their own, with the same entries as the provider's section. It wins over the provider entry by entry, so it is for wording one surface differently, not for translating the app:
<CommentThread
thread={thread}
labels={{
showMoreReplies: (count) => `Vis alle ${count}`,
comment: {actions: {delete: 'Fjern'}},
}}
/>From lowest to highest: the English defaults, the provider's labels, and the component's own labels. A component nested inside another gets its own labels from the outer one: a card passes its labels.comment to every comment it holds, and a comment passes its labels.input to its edit composer. comment.actions merges a level down, so a provider that renames Edit and a card that renames Delete both stick.
A composer's prompt and submit text are labels too. They come from three places, and each wins over the one before it:
- Provider defaults:
input.placeholderandinput.submitset them for every composer. - Per-composer overrides:
thread.replyPlaceholderfor the reply composers,panel.composerPlaceholderfor the panel's composers, andcomment.editPlaceholderandcomment.editSubmitfor the edit composer.panel.composerPlaceholderis given the composer'sCommentTarget, whose field path is''for a thread on the whole document. - Props, for one composer worded differently from the rest:
placeholderandsubmitLabelonCommentInputandInlineCommentInput, andreplyPlaceholderonCommentThread.
inline.button names the inline affordance, and label on InlineCommentInput wins over it. CommentBadge takes label for its accessible name, and CommentList takes empty for its empty state.
The defaults are exported as DEFAULT_COMMENT_LABELS, DEFAULT_COMMENT_INPUT_LABELS, DEFAULT_COMMENT_THREAD_CARD_LABELS, and DEFAULT_COMMENTS_PANEL_LABELS.
Translate the text inside a comment
Comment takes the strings of everything it renders or opens as one labels object: its actions menu, the delete confirmation, the failed-create notice, the edit composer, the edited marker, the "you" on a self-mention, the reaction picker, and the name of an author there is no user for. The provider's labels.comment is that same object less input, which the provider's own labels.input covers. For one comment worded differently:
import type {CommentData} from '@sanity/collaboration'
import {Comment} from '@sanity/collaboration/ui'
export function RemovableComment({comment}: {comment: CommentData}) {
return (
<Comment
comment={comment}
labels={{
actions: {delete: 'Fjern'},
deleteConfirm: 'Fjern',
input: {cancel: 'Lukk'},
}}
/>
)
}labels.actions is the same object commentActions takes, and labels.input the composer's own CommentInputLabels.
CommentThread passes labels.comment to every comment it holds, parent and replies. A CommentsPanel or SDKCommentsPanel reaches its cards through threadProps, which is how one panel's comments can read differently from the rest of the app:
import {SDKCommentsPanel} from '@sanity/collaboration/sdk'
import type {CommentLabels} from '@sanity/collaboration/ui'
import type {DocumentHandle} from '@sanity/sdk'
const reviewLabels: Partial<CommentLabels> = {
actions: {resolve: 'Godkjenn'},
}
export function ReviewPanel({handle}: {handle: DocumentHandle}) {
return (
<SDKCommentsPanel
{...handle}
// Keeps this panel's unsent drafts apart from another panel's.
draftScope="review"
threadProps={() => ({labels: {comment: reviewLabels}})}
/>
)
}Two panels over the same document share their unsent drafts. draftScope keeps this panel's drafts apart from the other's.
With CommentsPanel, return the labels beside the callbacks you already pass:
const threadCallbacks = useSDKThreadCallbacks(handle)
<CommentsPanel
threads={threads}
threadProps={(thread) => ({
...threadCallbacks(thread),
labels: {comment: reviewLabels},
})}
/>Pass the strings as props
The primitives a comment is built from take every string they render as a prop. A layout composed from them, as in Build a custom comment, passes them directly.
Anything that ends up as a button or an accessible name is named xxxLabel. The rest keeps a descriptive name.
<CommentHeader
author={author.user}
authorLoading={author.loading}
createdAt={comment.createdAt}
lastEditedAt={comment.lastEditedAt}
authorFallback="Ukjent"
editedLabel="(redigert)"
/>These are the strings each primitive renders, and what it falls back to:
Primitive | Prop | Default |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Reaction names are the exception: CommentReactions and AddReactionButton take them from the provider's labels.reactions.name, since a primitive that renders reactions is always under a provider.
Rename the default actions
Under the default components, the provider's labels.comment.actions renames them. A layout of your own that calls commentActions passes the names itself, as one object. Each entry it is not given keeps the English default:
commentActions({
comment,
capabilities,
onEdit,
onDelete,
labels: {edit: 'Rediger', delete: 'Slett'},
})The defaults are Resolve comment, Reopen comment, Retry, Edit, Copy link, and Delete. A seventh, discard, names the one button Comment shows on a comment that failed to post; it defaults to Discard, and commentActions does not use it.
Once the names are translated, do not filter the action list on action.name. Build the list without the callback you do not want, or filter before the labels are applied. See Customize comment actions.
Format timestamps
Timestamps need nothing. The provider's default formatTime uses Intl.RelativeTimeFormat with no explicit locale, so it already follows the runtime's. Pass formatTime to CommentsProvider or SDKCommentsProvider to override the format itself:
<CommentsProvider
currentUser={me}
getUser={getUser}
formatTime={(isoDate) => myFormatter.format(new Date(isoDate))}
>What cannot be translated yet
DeleteCommentDialog's close button takes its accessible name from Sanity UI, which does not expose it. It stays Close dialog. The discard confirmation that CommentInput opens with confirmDiscard has the same close button.
Next steps
- The primitives these props belong to, with every other prop: Primitives.
- Assembling a card that takes them: Build a custom comment.
- A panel with a language picker, as an example you can run: translate.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?