NSFW Toggle Behaviour
ADR: Always-visible NSFW toggle with context-dependent locking
Date: 2026-03-05 Linear: BEEF-1161 PRs: #2319 (toggle rework), #2302 (NSFW pricing + I2V/EiF locking)
Context
The NSFW toggle controls whether a generation request is processed through the NSFW pipeline. Prior to this change, toggle visibility and behaviour varied inconsistently across entry points — sometimes hidden entirely for certain media types (signatures, videos, carousels, KackMe), sometimes visible but with unclear defaulting.
The team raised concerns that this was confusing for users. Additionally, a compliance constraint exists: NSFW generations are routed through different payment processors than SFW ones. A user must not be able to submit an NSFW source image through the SFW pipeline, as this violates processor terms.
Decision
Visibility
The toggle is visible whenever both conditions are met:
nsfw-onboardingfeature flag is enabled (PostHog)- User has completed KYC verification (
isUserKycVerified)
If either condition is false, the component renders nothing.
Default state
On mount (and whenever the source media changes), the toggle defaults to:
isNsfw = mediaIsNsfw AND isSelectedCharacterNsfwBoth must be true — an NSFW source with a SFW character defaults to off, preventing accidental NSFW generation on a character that doesn’t support it.
Toggle locking
The toggle is visible but disabled (greyed out, tooltip: “Tied to the selected example”) in certain contexts. Locking is determined by isNsfwToggleLocked in useNsfwOnboarding:
| Context | Locked? | Why |
|---|---|---|
Create page / blank gen (no flags, no initialPrompt) | No | User is starting from scratch, free to choose |
Any media with initialPrompt set (explore examples, copy-prompt, gallery reuse) | Yes | Content is tied to a specific example; NSFW state must match the source |
| Signature / video / carousel examples | Yes | Same as above — isSignature, isVideo, or isCarousel flag makes it non-simple |
| KackMe | Yes | isCarousel flag is set, making it non-simple |
| Edit (EiF) with SFW source (non-free edit) | No | User may nudify a SFW starting image |
| Edit (EiF) with SFW source (free edit) | Yes | User may abuse credits |
| Edit (EiF) with NSFW source | Yes | Can’t use SFW pipeline on NSFW source (compliance) |
| I2V with SFW source | No | User may nudify a SFW starting image |
| I2V with NSFW source | Yes | Can’t use SFW pipeline on NSFW source (compliance) |
The locking logic in code:
if (imageEditMode) return !!mediaIsNsfw; // locked only if source is NSFWif (imageToVideoMode) return !!mediaIsNsfw; // locked only if source is NSFW
const isSimplePrompt = !isVideo && !isSignature && !isCarousel && !imageToVideoMode && !imageEditMode && !initialPrompt;if (isSimplePrompt) return false; // blank gen — unlocked
return true; // everything else — lockedToggle interaction
When the user clicks the toggle:
- If locked → no-op (early return)
- If turning ON (
checked=true) and arequiredActionexists → execute the action (paywall, character selector, etc.) instead of toggling - Otherwise → set
isNsfwto the new value
Turning OFF never triggers requiredAction — the if (checked && requiredAction) guard ensures this. Users can always disable NSFW without being gated.
Required actions (gating NSFW enable)
When the toggle is unlocked, enabling NSFW may still be blocked if the user hasn’t completed onboarding steps. getNsfwRequiredActionForCreate returns a callback that redirects the user:
| Condition | Action |
|---|---|
| No NSFW-capable subscription (starter/plus tier) | Paywall → upgrade to creator/ultimate |
| Has subscription, selected character is NSFW-onboarded | undefined (proceed normally) |
| Has subscription, other NSFW characters available | Toast + character selector |
| Has subscription, draft NSFW character exists | Finish onboarding dialog |
NSFW pricing
NSFW generations cost 5× credits (NSFW_CREDITS_MULTIPLIER = 5). This multiplier applies to all media types: images, videos, carousels. Pricing is calculated in getPromptCreditsPrice() based on promptData.isNsfw.
NSFW classification of source media
isMediaExampleNsfw(explicitnessLevel) returns true for any level other than SFW or SSFW. This means NSFW, EXPLICIT, and UNSPECIFIED are all treated as NSFW.
Subscription tiers
NSFW generation is gated behind nsfwSupported on the plan:
| Tier | NSFW supported |
|---|---|
| Starter | No |
| Plus | No |
| Creator | Yes |
| Ultimate | Yes |
| Ultinext | Yes |
Rationale
Alternative 1 — Hide the toggle when state is inherited (previous behaviour). The toggle was invisible for signatures, videos, carousels, and KackMe. NSFW state was silently inherited from the source. Rejected because users couldn’t tell whether their generation was NSFW, leading to confusion and support requests.
Alternative 2 — Always allow toggling in both directions. Let users freely switch between SFW and NSFW regardless of source content. Rejected because allowing NSFW→SFW on an NSFW source image violates payment processor compliance — the SFW pipeline cannot process NSFW content.
Alternative 3 — Lock the toggle for all non-blank-gen contexts (initial PR #2319 approach). Every example, edit, and I2V flow locked the toggle. Refined in PR #2302 to unlock Edit/I2V when the source is SFW, since users should be able to opt into NSFW for their own SFW images.
The chosen approach maximises user control while enforcing the one hard constraint: NSFW source content cannot flow through SFW processors.
Consequences
Positive:
- Users always see the toggle state — no more silent NSFW inheritance
- Locked state is visually communicated (opacity, tooltip), reducing confusion
- Compliance is enforced at the UI level — NSFW sources can’t be switched to SFW
Negative:
- Edit/I2V locking is asymmetric (SFW→NSFW allowed, NSFW→SFW blocked), which may feel inconsistent to users unfamiliar with the compliance reason
Key files
| File | Purpose |
|---|---|
apps/brisket/src/features/create-image/components/nsfw-toggle/index.tsx | Toggle component — rendering, click handling, useEffect for default |
apps/brisket/src/features/character-page-ccv4/hooks/use-nsfw-onboarding.tsx | Core hook — availability, locking, required actions, subscription checks |
apps/brisket/src/features/character-page-ccv4/helper-functions.tsx | isMediaExampleNsfw — classifies explicitness levels |
apps/brisket/src/utils/credits-price.ts | getPromptCreditsPrice — applies NSFW multiplier |
apps/brisket/src/lib/constants.ts | NSFW_CREDITS_MULTIPLIER = 5 |
apps/brisket/src/features/checkout/components/constants.ts | Plan definitions with nsfwSupported flag |